1#!BPY
2
3# Blender exporter for UFO:AI.
4
5# Copyright 2008 (c) Wrwrwr <http://www.wrwrwr.org>
6
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16
17# You should have received a copy of the GNU General Public License
18# along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20# --------------------------------------------------------------------------
21# Changelog
22# 	0.2.1	mattn fixed missing texture and removed stepon
23# 	0.2	WRWRWR Initial version
24# --------------------------------------------------------------------------
25
26
27"""
28Name: 'UFO:AI (.map)'
29Blender: 245
30Group: 'Export'
31Tooltip: 'Export to UFO:AI map format.'
32"""
33
34__author__ = 'Wrwrwr'
35__version__ = '0.2.1'
36__email__ = 'ufoai@wrwrwr.org'
37__bpydoc__ = '''\
38Exports current scene as an UFO:AI map.
39
40See the full documentation at: http://www.wrwrwr.org/b2ufo/.
41
42Simple convex meshes, lights, models and generic entities are exported.
43'''
44
45from math import *
46from numpy import *
47from numpy.linalg import *
48
49from copy import copy, deepcopy
50from cStringIO import StringIO
51from datetime import datetime
52from logging import Filter, basicConfig, debug, error, getLogger, info, warning
53from os.path import exists, isabs, join, normpath, realpath, splitext
54from os import sep
55from sys import stderr
56from time import clock
57
58from Blender import *
59import BPyMesh # for faceAngles
60
61
62
63# planes for Q2 texture mapping; normal, its non zero indices
64# "natural" texture mapping -- horrible, moreover the planes have different handedness
65PLANES = (
66(( 0,  0,  1), (0, 1)),			# floor, lh
67(( 0,  0, -1), (0, 1)),			# ceiling, rh
68(( 1,  0,  0), (1, 2)),			# west wall, lh
69((-1,  0,  0), (1, 2)),			# east wall, rh
70(( 0,  1,  0), (0, 2)),			# south wall, rh
71(( 0, -1,  0), (0, 2))			# north wall, lh
72)
73
74# dummy object needed only for triangulation in splitting *** move to export
75do = Object.New('Mesh', 'Dummy Exporter Object')
76
77# dummy mesh used for exporting meshes without changing them *** move to export
78dm = Mesh.New()
79
80# ~rotation matrices used in texture mapping, *** move this to export (for configurable sum weighting)
81a0 = matrix([[ 0,  1], [ 1,  0]])
82a1 = matrix([[ 1,  0], [ 0, -1]])
83asit = (a0 + a1).I.T
84
85
86
87def normalize(vector):
88	'''
89	Normalize a numpy array. Should be a numpy function.
90	'''
91	return vector / norm(vector)
92
93
94
95def needsSplitting(mesh, scale):
96	'''
97	Checks if it's ok to export mesh as it is. Must be strictly convex.
98	Connectivity: checks if every edge is used by exactly two faces.
99	Convexity: checks if every two normals of neighbouring faces intersect behind them.
100	This should also work for all cases that are not polyhedra, but are exportable.
101	Complexity: O(number of edges).
102	*** check if the mesh is at all connected?
103	*** are these tests enough
104	'''
105	# make a dictionary, where the edge is the key, and a list of faces that use it is the value
106	esfs = dict([(e.key, []) for e in mesh.edges])
107	for f in mesh.faces:
108		for ek in f.edge_keys:
109			esfs[ek].append(f)
110
111	# check normals of every two neighbouring faces
112	for ek, fs in esfs.iteritems():
113		if len(fs) != 2:
114			warning('\t\tNeeds splitting: An edge is not used by exactly 2 faces.')
115			return True
116		if dot(fs[0].no, fs[1].cent - fs[0].cent) >= 0:
117			warning('\t\tNeeds splitting: Concave around: %s.' % around((fs[0].cent + fs[1].cent) / 2 / scale, 2))
118			'''
119			# sometimes a handy way to help in fixing those 'where is it concave?' things
120			mesh.mode = Mesh.SelectModes['FACE']
121			fs[0].sel = fs[1].sel = 1
122			mesh = mesh.__copy__()
123			sob = Object.New('Mesh', 'Concave Debug')
124			Scene.getCurrent().objects.link(sob)
125			sob.link(mesh)
126			'''
127			return True
128
129	'''
130	# check face areas, that's only needed with early coordinates rounding
131	# such checks would only be needed without enlarging coordinates
132	for f in mesh.faces:
133		if f.area < minFaceArea:
134			warning('\t\tNeeds splitting: A very small face present.')
135			return True
136	'''
137	return False
138
139
140# *** advanced, efficient splitting needed (e.g.: add as many faces as will allow to close polyhedron with a single vertex leaving it convex, like incremental convex hull)
141# *** how to deal with very close opposite faces?
142# *** if using rounded vertex coordinates, ascertain that the added vertices have integer coordinates too
143def split(mesh, splitHeight):
144	'''
145	Split the mesh into tetra-, pentahedra, one for every face.
146	Very close opposite faces (like a concave thin wall) will break visibility info.
147	'''
148	# we'll return a list of new meshes, together making the original one
149	ms = []
150	for f in mesh.faces:
151		vs = [v.co for v in f.verts]
152
153		# check if the face won't cause trouble
154		if len(vs) != 3:
155			warning('\t\tSkipping a face not being a triangle, with %d vertices.' % len(vs))
156			continue
157		if f.area < minFaceArea:
158			warning('\t\tSkipping a face with very small area: %.2f.' % f.area)
159			continue
160		as = filter(lambda a: a < minFaceAngle, BPyMesh.faceAngles(f)) # *** just till the first is found
161		if as:
162			warning('\t\tSkipping a face with a very small angle: %.2f.' % as[0])
163			continue
164		es = filter(lambda ei: mesh.edges[ei].length < minEdgeLength, mesh.findEdges(f.edge_keys)) # *** same
165		if es:
166			warning('\t\tSkipping a face with a very short edge: %.2f.' % mesh.edges[es[0]].length)
167			continue
168
169		# make a new tetrahedron, watch vertex order not to flip any normals
170		m = Mesh.New()
171		m.verts.extend(vs)
172		m.verts.extend(f.cent - f.no * splitHeight)
173		m.faces.extend(((0, 1, 2), (1, 0, 3), (2, 1, 3), (0, 2, 3)))
174		m.faces[0].image = f.image
175		m.faces[0].uv = f.uv
176		ms.append(m)
177	return ms
178	'''
179	# new faces should have similar area to the original face?
180	m.verts.extend(f.cent - f.no * sqrt(f.area) * 1.24 * splitHeight)
181
182	# cuboids, a bit more precise for small faces
183	m.verts.extend([v.co - f.no * sqrt(f.area) * splitHeight for v in f.verts])
184	m.faces.extend(((0, 1, 2), (1, 0, 3, 4), (2, 1, 4, 5), (0, 2, 5, 3), (5, 4, 3)))
185	'''
186
187
188
189def Q2Texture(face):
190	'''
191	Texture mapping info in the Quake 2 format: offset u, offset v, rotation, scale u, scale v;
192	all in the one of the base planes (ceiling, west, south etc.); offsets and rotation with respect to the scene center.
193	Note that, because textures are actually mapped to one of the base planes and not the face's plane, it's not possible to properly map streched textures,
194	or textures to faces when the projections to the base plane of the image edges' in the scene aren't perpendicular.
195	Face is assumed to have an image texture, and to be strictly convex.
196	*** still requires review, add lack of inverse exception handling
197	*** this doesn't work well for textures stretched on quads (completely ignores one vertex)
198	'''
199	# a plane, the texture is actually mapped to; one of the base planes, the normal of which is the closest to the face normal
200	d, (tn, tnnzis) = max(map(lambda p: (dot(face.no, p[0]), p), PLANES))
201
202	# project face points to the base plane (any vertices in any order may be used here) *** quad with three vertices in a line problem
203	vs = array([v.co for v in face.verts])[:, tnnzis]
204	c = array(face.cent)[tnnzis, ...] # *** c doesn't have to be an array
205
206	# image data, uv coordinates must match vertices
207	size = face.image.getSize()
208	uvs = array(face.uv)
209
210	# face edges in the scene coordinates and in the image coordinates
211	edgesScene = matrix([vs[1] - vs[0], vs[2] - vs[0]])
212	edgesImage = matrix([uvs[1] - uvs[0], uvs[2] - uvs[0]])
213
214	# bottom and left edge of the image, or uv unit vectors, in the scene coordinates
215	# (uvs may lie on a line -- noninvertible, *** a better approximation of offset and rotation is needed in such cases)
216	try:
217		units = asarray(edgesImage.I * edgesScene)
218	except LinAlgError:
219		warning('\t\tA texture line to be mapped onto a face, uvs: %s.' % [tuple(around(v, 2)) for v in face.uv])
220		units = asarray(pinv(edgesImage) * edgesScene) # *** does the Moore-Penrose make sense here at all?
221
222	# face center uv coordinates (we'll try to put the same texture point on the face center as blender does)
223	try:
224		uvc = uvs[0] + ravel(matrix(c - vs[0]) * edgesScene.I * edgesImage)
225	except LinAlgError:
226		error('\t\tBUG: A line face in texture mapping.')
227		return ' 0 0 0 1 1'
228
229	# unscaled scale
230	scale = apply_along_axis(norm, 1, units)
231
232	# normalize the unit vectors
233	units[0] /= scale[0]
234	units[1] /= scale[1]
235
236	# quake understands the left edge upside down, or blender does
237	scale[1] *= -1
238	# uvs[:, 1] *= -1, not used
239	uvc[1] *= -1
240
241	# units and base axes have the same orientation?, if not flip one of the units (projected base axes cross is always -1)
242	if cross(units[0], units[1]) > 0:
243		units[0] *= -1
244		scale[0] *= -1
245
246	# rotation angle (around (0,0,0)), such an angle that the base rotated by it gives the texture units' projections
247	# or rather the sum of the base axes gives the sum of the units, any better idea for stretched textures? *** implement the configurable weighting
248	rotation = atan2(*ravel(dot(units[0] + units[1], asit)))
249
250	# offsets will be calculated with respect to the rotated base
251	r = matrix([[sin(rotation)], [cos(rotation)]])
252
253	# offset (from (0,0,0), in lengths of units' projections)
254	# *** shouldn't this be matrix(c - vs[0])? think this over again
255	offset = uvc - ravel(matrix(c) * hstack([a0 * r, a1 * r])) / scale
256
257	# in degrees, rounded
258	rotation = round(rotation * 180.0 / pi) % 360
259
260	# in images
261	scale /= size
262
263	# in images, rounded
264	offset = around((offset * size) % size)
265
266	return '%d %d %d %f %f' % (offset[0], offset[1], rotation, scale[0], scale[1])
267
268
269
270def writeFace(file, face, flags, coordinatesMultiplier, imagePaths, missingImage):
271	'''
272	Writes the face description to the file.
273	Writes three vertices, texture information (name, u offset, v offset, rotation, u scale, v scale), and flags.
274	'''
275	# write three points in the face plane (in a counterclock-wise order)
276	vs = [face.verts[-1].co, face.verts[1].co, face.verts[0].co]
277
278	# large coordinates give better approximation of plane normal and distance, but an edge shared by two different meshes may come out as two different edges
279	if coordinatesMultiplier == 1:
280		ps = vs
281	else:
282		ps = array([vs[1] - vs[0], vs[2] - vs[1], vs[0] - vs[2]], dtype=longdouble) # some increased precision won't hurt here
283		ps = apply_along_axis(normalize, 1, ps) * coordinatesMultiplier + vs
284	file.write('( %d %d %d ) ( %d %d %d ) ( %d %d %d )' % tuple(around(ravel(ps))))
285
286	# write texture information, it's assumed here that the mesh has an uv layer
287	if face.image:
288		file.write(' %s %s' % (imagePaths[face.image], Q2Texture(face)))
289	else:
290		flags = (0, 128, 0) # 16 = ~transparent, 128 = don't draw, 512 = completely skip (breaks visibility)
291		file.write(' %s 0 0 0 1 1' % missingImage)
292
293	# write flags, *** all flags handling not just level flags (handling somewhat above or at writeMesh)
294	file.write(' %d %d %d\n' % tuple(flags))
295
296	'''
297	# write a lot of debugging info to the map file (should still compile)
298	file.write('// normal: %s, center: %s, area: %s\n' % (face.no, face.cent, face.area))
299	file.write('// vertices: %s\n' % [v.co for v in face.verts])
300	file.write('// edges lengths: %f %f %f\n' % (norm(vs[2] - vs[0]), norm(vs[1] - vs[0]), norm(vs[2] - vs[1])))
301	file.write('// face angles: %s\n' % str(BPyMesh.faceAngles(face)))
302	v1, v2 = normalize(vs[1] - vs[0]), normalize(vs[2] - vs[0])
303	pr1, pr2 = normalize(around(ps[1] - ps[0])), normalize(around(ps[2] - ps[0]))
304	file.write('// original plane vectors: %s %s, quality: %f %f %f\n' % (v1, v2, dot(v1, face.no), dot(v2, face.no), dot(vs[0], face.no)))
305	file.write('// written plane vectors:  %s %s, quality: %f %f %f\n// ---------------------\n' % (pr1, pr2, dot(pr1, face.no), dot(pr2, face.no), dot(ps[0], face.no)))
306	'''
307
308
309def writeMesh(file, mesh, scale, quadToleration, splitMethod, splitHeight, minFaceArea, minFaceAngle, minEdgeLength, coordinatesMultiplier, imagePaths, missingImage, stats):
310	'''
311	Write a Blender mesh to the map file. It will be split if needed (if it's not strictly concave).
312	Writes game logic properties as entity properties, then writes faces of one or more brushes as needed.
313	Level visibility is set to above object's geometric center (calculated, not from Blander). You can override this with the 'levels' property.
314	'''
315	# make a copy of the mesh data
316	dm.getFromObject(mesh)
317	do.link(dm)
318
319	# *** some more checks to see if at all exportable
320	if not dm.faces:
321		warning('\t\tIgnoring a mesh without faces.')
322		return
323
324	if not dm.faceUV:
325		warning('\t\tNo uv layer. Only uv-mapped textures are supported.')
326		dm.addUVLayer('Dummy uv layer.')
327
328	# apply object transform to all vertices to get their world coordinates
329	dm.transform(mesh.matrix * scale, recalc_normals=True)
330
331	# round vertices before performing any calculations (goes with writing them verbatim later),
332	# this can turn some faces completely, however sometimes is better at avoiding breaks in split meshes
333	if coordinatesMultiplier == 1:
334		for v in dm.verts:
335			v.co = Mathutils.Vector(around(v.co))
336
337	# triangulate all nonplanar quads
338	dm.sel = False
339	for f in dm.faces:
340		if len(f.verts) == 4:
341			vs = [v.co for v in f.verts]
342			es = [vs[i] - vs[(i+1) % 4] for i in range(4)]
343			if not alltrue([abs(dot(es[i], f.no)) < quadToleration * norm(es[i]) for i in range(4)]):
344				f.sel = True
345	dm.quadToTriangle()
346
347	# split the mesh if needed and requested
348	if needsSplitting(dm, scale):
349		if splitMethod: # *** just one (not really working) method at the moment :)
350			ms = split(dm)
351		else:
352			warning('\t\tSplitting Disabled!')
353			ms = [dm]
354	else:
355		ms = [dm]
356
357	# defined game properties
358	ps = dict((p.name, p.data) for p in mesh.game_properties)
359
360	# flags property
361	if 'flags' in ps:
362		flags = map(int, ps['flags'].split(None, 2))
363		del ps['flags']
364	else:
365		flags = [0, 0, 0]
366
367	# level visibility
368	if 'levels' in ps:
369		lf = sum(map(lambda x: 1 << x if ps['levels'].find(str(x+1)) != -1 else 0, range(8)))
370		stats['levels'] = max(stats['levels'], max(map(int, ps['levels'].split())))
371		del ps['levels']
372	else:
373		vs = dm.verts
374		l = int(max(0, min(7, floor(sum(v.co.z for v in vs) / len(vs) / scale / 2))))
375		lf = sum(map(lambda x: 1 << x, range(l, 8)))
376		stats['levels'] = max(stats['levels'], l+1)
377	flags[0] ^= 256 * lf
378
379	# special properties
380	if 'actorclip' in ps:
381		flags = [65536, 0, 0] # ignore the rest of the flags intentionally
382		del ps['actorclip']
383	if 'trans33' in ps:
384		flags[1] ^= 16
385		del ps['trans33']
386	if 'trans66' in ps:
387		flags[1] ^= 32
388		del ps['trans66']
389
390	# write all the brushes needed to render this mesh
391	for i in range(len(ms)):
392		file.write('// Brush %d (%s %d)\n{\n' % (stats['brushes'], mesh.name, i))
393		for p in sorted(ps.iteritems()):
394			file.write('"%s" "%s"\n' % p)
395		for f in ms[i].faces:
396		    writeFace(file, f, flags, coordinatesMultiplier, imagePaths, missingImage)
397		file.write('}\n')
398		stats['faces'] += len(ms[i].faces)
399		stats['brushes'] += 1
400	stats['meshes'] += 1
401
402
403def writeLight(file, light, scale, energyMultiplier, stats):
404	'''
405	Write a light to the map file.
406	Writes game logic properties as entity properties adding light, color and origin if not already defined.
407	Light intensity is blenders energy multiplied by the multiplier.
408	'''
409	# add intensity, color and origin
410	ps = dict()
411	ps['classname'] = 'light'
412	ps['light'] = '%f' % (light.data.energy * energyMultiplier)
413	ps['_color'] = '%f %f %f' % tuple(light.data.col)
414	ps['origin'] = '%d %d %d' % tuple(around(array(light.loc) * scale))
415	ps.update(dict((p.name, p.data) for p in light.game_properties))
416
417	# write the entity
418	file.write('// %s\n{\n' % light.name)
419	for p in sorted(ps.iteritems()):
420		file.write('"%s" "%s"\n' % p)
421	file.write('}\n')
422	stats['lights'] += 1
423
424
425def writeModel(file, model, scale, modelsFolder, stats):
426	'''
427	Writes model entity to the file. Path ("model" property) is required and should begin with models/.
428	Adds angles, origin and spawnflags to the defined properties (if not already set).
429	You can use "levels" property as with meshes, overriding spawnflags.
430	'''
431	# check and normalize the path, can be absolute or relative to modelsFolder
432	try:
433		p = model.getProperty('model')
434	except RuntimeError:
435		p = None
436	if not p:
437		warning('\t\tModel does not have path set ("model" property).')
438		return
439	p = p.data
440	if isabs(p):
441		p = realpath(p)
442		if not p.startswith(modelsFolder):
443			warning('\t\tModel is outside of models base: %s.' % p)
444			return
445	else:
446		p = realpath(join(modelsFolder, p))
447	if not exists(p):
448		warning('\t\tModel doesn\'t exist. Bad path set (%s).' % p)
449		return
450	p = p[len(modelsFolder)+1:]
451
452	# level -- spawnflags
453	l = int(max(0, min(7, floor(model.LocZ / 2))))
454
455	# define origin and angles (pitch, yaw, roll), update with user-defined game properties
456	ps = dict()
457	ps['origin'] = '%d %d %d' % tuple(around(array(model.loc) * scale))
458	ps['angles'] = '%d %d %d' % tuple(around([a * 180.0 / pi for a in (model.RotX, model.RotZ - pi / 2, -model.RotY)]))
459	ps['spawnflags'] = sum(map(lambda x: 1 << x, range(l, 8)))
460	ps.update(dict((p.name, p.data) for p in model.game_properties))
461
462	# update with the normalized path
463	ps['model'] = p
464
465	# override spawnflags with levels if present
466	if 'levels' in ps:
467		ps['spawnflags'] = sum(map(lambda x: 1 << x if ps['levels'].find(str(x+1)) != -1 else 0, range(8)))
468		del ps['levels']
469
470	# write it
471	file.write('// %s\n{\n' % model.name)
472	for p in sorted(ps.iteritems()):
473		file.write('"%s" "%s"\n' % p)
474	file.write('}\n')
475	stats['levels'] = max(stats['levels'], l+1)
476	stats['models'] += 1
477
478
479def writeEntity(file, entity, scale, stats):
480	'''
481	Write generic entity to the map file.
482	Writes game logic properties as entity properties, adds origin and angle.
483	The classname must be already set as game property is required.
484	'''
485	# add angle and origin
486	ps = dict()
487	ps['origin'] = '%d %d %d' % tuple(around(array(entity.loc) * scale))
488	ps['angle'] = '%d' % round(entity.RotZ * 180.0 / pi)
489	ps.update(dict((p.name, p.data) for p in entity.game_properties))
490
491	# write it out, including properties
492	file.write('// %s\n{\n' % entity.name)
493	for p in sorted(ps.iteritems()):
494		file.write('"%s" "%s"\n' % p)
495	file.write('}\n')
496
497	# statistics have separate entries for different spawns
498	if ps['classname'] == 'info_alien_start':
499		stats['aliens'] += 1
500	elif ps['classname'] == 'info_human_start':
501		stats['humans'] += 1
502	elif ps['classname'] == 'info_player_start':
503		if not ps['team']:
504			warning('Player spawn with no team set.')
505		stats['teams'] = max(stats['teams'], ps['team'])
506		stats['players'] += 1
507	else:
508		stats['entities'] += 1
509
510
511
512def export(fileName):
513	'''
514	Exports meshes, lights and all that has classname game property as entities.
515	'''
516	# logging and printing configuration
517	basicConfig(level=15, format='%(message)s', stream=stderr)
518	set_printoptions(precision=2, suppress=True)
519
520
521	# general options
522	pathField = Draw.Create(normpath('../ufoai'))
523	scaleField = Draw.Create(32.0)
524	quadTolerationField = Draw.Create(0.01)
525	coordinatesMultiplierField = Draw.Create(65536)
526
527	# light options
528	energyMultiplierField = Draw.Create(500)
529	sunlightButton = Draw.Create(True)
530	sunlightIntensityField = Draw.Create(160)		# day
531	sunlightColorField = Draw.Create('1.0 0.8 0.8')	# white
532	sunlightAnglesField = Draw.Create('30 210')		# ~noon
533	ambientButton = Draw.Create(True)
534	ambientColorField = Draw.Create('0.4 0.4 0.4')	# just as example
535
536	# splitting options
537	splitButton = Draw.Create(False)
538	splitHeightField = Draw.Create(3.5)
539	minFaceAreaField = Draw.Create(0.5) 	# 3.5 should be enough in most cases
540	minFaceAngleField = Draw.Create(5) 		# 3.5 should be enough in most cases
541	minEdgeLengthField = Draw.Create(1) 	# 0.8 usually is enough
542	sceneCopyButton = Draw.Create(False)
543	leaveDummiesButton = Draw.Create(False)
544	faceWarningsButton = Draw.Create(False)
545
546	# ***
547	def globalLightButtonCallback(event, value):
548		print event, value;
549
550	# that's a popup
551	fs = (
552	('UFO Path:'),
553	('', pathField, 0, 256, 'Path to the top ufo folder.'),
554	(' '),
555	('General:'),
556	('Scale:', scaleField, 0.1, 100.0, 'Scale everything by this value.'),
557	('Quad toleration:', quadTolerationField, 0.0, 1.0, 'Consider quad nonplanar if absolute cosine between an edge and normal is higher.'),
558	('Coordinates Multiplier:', coordinatesMultiplierField, 1, 131072, 'Enlarge plane coordinates to mitigate rounding errors. Set 1 to disable.'),
559	(' '),
560	('Light:'),
561	('Energy multiplier:', energyMultiplierField, 0, 10000, 'Light intensity for lamps, energy multiplier.'),
562	('Sunlight', sunlightButton, 'Enable sunlight.'),
563	('Sunlight intensity:', sunlightIntensityField, 0, 10000, 'Global, parallel light intensity.'),
564	('Sunlight color:', sunlightColorField, 0, 100, 'Color of the parallel light. Three floats.'),
565	('Sunlight angles:', sunlightAnglesField, 0, 100, 'Angles of the parallel light. In degrees.'),
566	('Ambient', ambientButton, 'Enable ambient lighting.'),
567	('Ambient color:', ambientColorField, 0, 100, 'Ambient lighting color. Three floats.'),
568	('Concave splitting:'),
569	('Enable', splitButton, 'Make a tetra-, pentahedron for every face, doesn\'t work too well.'),
570	('Splitting Height:', splitHeightField, 0.01, 100.0, 'Pyramid height for split faces.'),
571	('Min. Face Area:', minFaceAreaField, 0, 100.0, 'Skip faces with area (after scaling) lower than this value.'),
572	('Min. Face Angle:', minFaceAngleField, 0, 60.0, 'Skip faces with angles lower than this value (degrees).'),
573	('Min. Edge Length:', minEdgeLengthField, 0, 100.0, 'Skip faces with edges shorter than that (after scaling).'),
574	(' '),
575	(' '),
576	('Debugging:'),
577	('Copy Scene', sceneCopyButton, 'Operate on a deep scene copy.'),
578	('Leave Dummy', leaveDummiesButton, 'Don\'t unlink the dummy object.'),
579	('Face Warnings', faceWarningsButton, 'Warn about faces ignored during splitting.'),
580	)
581
582	if not Draw.PupBlock('UFO:AI map export', fs):
583		return
584
585	Window.WaitCursor(1)
586
587	Window.EditMode(0) # *** reenter if enabled
588
589	st = clock()
590
591	info('Exporting: %s.' % fileName)
592
593	# written things counters
594	stats = { 'meshes' : 0, 'lights' : 0, 'models' : 0, 'entities' : 0, 'faces' : 0, 'brushes' : 0, 'aliens' : 0, 'humans' : 0, 'players' : 0, 'teams' : 0, 'levels' : 0 }
595
596	# options
597	#	opts = { 'scale' : scaleField.val, 'light' : lightField.val, ...
598
599	# if requested, operate on a deep scene copy
600	if sceneCopyButton.val:
601		sceneToExport = Scene.GetCurrent()
602		scene = sceneToExport.copy(2)
603	else:
604		scene = Scene.GetCurrent()
605
606	# only textures inside base/textures folder will get exported, only models inside base, all paths will be relative to this base folders
607	#*** there should be three 'missing' textures: not texture at all, outside base, split
608	texturesFolder = realpath(join(pathField.val, normpath('base/textures')))
609	if not exists(texturesFolder):
610		error('The textures base folder (%s) does not exist. Incomplete ufo or a bad path.' % texturesFolder)
611		return
612	modelsFolder = realpath(join(pathField.val, normpath('base')))
613	if not exists(modelsFolder):
614		error('The models base folder (%s) does not exist. Incomplete ufo or a bad path.' % modelsFolder)
615		return
616	missingImage = normpath('tex_common/nodraw.tga')
617	if not exists(realpath(join(texturesFolder, missingImage))):
618		error('The missing texture (%s) is missing.' % missingImage)
619		return
620	missingImage = splitext(missingImage)[0]
621
622	# preprocess image paths, find paths relative to base/textures, *** how to get images just from the current scene?
623	info('Processing images.')
624	imagePaths = {}
625	for i in Image.Get():
626		p = normpath(sys.expandpath(i.getFilename())).split(normpath('base/textures'), 1)
627		if len(p) != 2:
628			warning('\tAn image outside of the base/textures folder: %s.' % i.getFilename())
629			p = missingImage
630		else:
631			p = p[1]
632			if p.startswith(sep):
633				p = p[len(sep):]
634			p = realpath(join(texturesFolder, p))
635			if not exists(p):
636				warning('\tAn image doesn\'t exist: %s.' % p)
637				p = missingImage
638			else:
639				p = splitext(p[len(texturesFolder)+1:])[0]
640		imagePaths[i] = p
641
642	# warnings about skipped faces are to verbose sometimes
643	if not faceWarningsButton.val:
644		class FaceWarningsFilter(Filter):
645			def filter(self, record):
646				return not record.getMessage().startswith('\t\tSkipping a face')
647		getLogger().addFilter(FaceWarningsFilter())
648
649	# classify and scale (look for the classname first, then decide by object type)
650	info('Classifying.')
651	meshes = []
652	lights = []
653	models = []
654	entities = []
655	for o in scene.objects:
656		if o.name.startswith('Dummy Exporter'):
657			warning('\tA probable exception leftover, ignoring: %s.' % o.name)
658			continue
659		try:
660			cn = o.getProperty('classname')
661		except RuntimeError: # hail Blender ...
662			cn = None
663		if cn:
664			if cn.data == 'misc_model':
665				models.append(o)
666			else:
667				entities.append(o)
668		else:
669			if o.type == 'Mesh':
670				meshes.append(o)
671			elif o.type == 'Lamp':
672				lights.append(o)
673			else:
674				warning('\tIgnoring an object: %s.' % o.name)
675
676	# write to memory, flush later
677	world = StringIO()
678	rest = StringIO()
679
680	# export meshes
681	if meshes:
682		info('Exporting meshes:')
683		scene.objects.link(do) # dummy object needed for triangulating
684		for m in meshes:
685			info('\t%s' % m.name)
686			writeMesh(world, m, scaleField.val, quadTolerationField.val, splitButton.val, splitHeightField.val, minFaceAreaField.val, minFaceAngleField.val, minEdgeLengthField.val, coordinatesMultiplierField.val, imagePaths, missingImage, stats)
687		if not leaveDummiesButton.val:
688			scene.objects.unlink(do)
689
690	# export lights
691	if lights:
692		info('Exporting lights:')
693		for l in lights:
694			info('\t%s' % l.name)
695			writeLight(rest, l, scaleField.val, energyMultiplierField.val, stats)
696
697	# export models
698	if models:
699		info('Exporting models:')
700		for m in models:
701			info('\t%s' % m.name)
702			writeModel(rest, m, scaleField.val, modelsFolder, stats)
703
704	# export other entities (spawns, models)
705	if entities:
706		info('Exporting entities:')
707		for e in entities:
708		    info('\t%s' % e.name)
709		    writeEntity(rest, e, scaleField.val, stats)
710
711	# get rid of the scene copy
712	if sceneCopyButton.val:
713		sceneToExport.makeCurrent()
714		Scene.Unlink(scene)
715
716	# check some general things
717	if stats['aliens'] == 0:
718		warning('Map does not have any alien spawns (classname info_alien_start).')
719
720	if stats['humans'] == 0:
721		warning('Map does not have any human spawns (classname info_human_start).')
722
723	if stats['teams'] == 0:
724		warning('Map does not have any multiplier spawns (classname info_player_start).')
725	# *** check some more, any lights?
726
727	# write to disk
728	file = open(fileName, 'wb')
729	file.write('// Exported on: %s.\n' % datetime.now())
730	file.write('{\n')
731	file.write('"classname" "worldspawn"\n')
732	if sunlightButton.val:
733		file.write('"light" "%d"\n' % sunlightIntensityField.val) # *** check light values
734		file.write('"_color" "%s"\n' % sunlightColorField.val)
735		file.write('"angles" "%s"\n' % sunlightAnglesField.val)
736	if ambientButton.val:
737		file.write('"ambient" "%s"\n' % ambientColorField.val)
738	file.write('"maxlevel" "%d"\n' % (stats['levels'] + 1)) # need to add one in case of actor getting to the map top
739	file.write('"maxteams" "%d"\n' % stats['teams'])
740	file.write(world.getvalue())
741	file.write('}\n')
742	file.write(rest.getvalue())
743	file.close()
744
745	# bah
746	s = lambda n: [n, '' if n == 1 else 's']
747	es = lambda n: [n, '' if n == 1 else 'es']
748	ies = lambda n: [n, 'y' if n == 1 else 'ies']
749	info(str(stats))
750	info('%d mesh%s, %d light%s, %d model%s, %d other entit%s, done in %.2f second%s.' % tuple(es(stats['meshes']) + s(stats['lights']) + s(stats['models']) + ies(stats['entities']) + s(clock() - st)))
751
752	Window.WaitCursor(0)
753
754
755
756def main():
757	# *** some default reasonable for everyone
758	Window.FileSelector(export, 'UFO:AI map export', realpath(normpath('../ufoai/base/maps/blenderd.map')))
759	'''
760	# profiling, open stats with pprofui for example
761	from profile import run
762	from os.path import expanduser
763	run("from os.path import *; from ufoai_export import export; export(realpath(normpath('../ufoai/base/maps/blender.map')))", expanduser('~/stats'))
764	'''
765
766if __name__ == '__main__': main()
767