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