Re-Add Quake/Doom3 map exporter.
[blender-addons-contrib.git] / io_scene_map / export_map.py
1 # ##### BEGIN GPL LICENSE BLOCK #####
2 #
3 #  This program is free software; you can redistribute it and/or
4 #  modify it under the terms of the GNU General Public License
5 #  as published by the Free Software Foundation; either version 2
6 #  of the License, or (at your option) any later version.
7 #
8 #  This program is distributed in the hope that it will be useful,
9 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
10 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 #  GNU General Public License for more details.
12 #
13 #  You should have received a copy of the GNU General Public License
14 #  along with this program; if not, write to the Free Software Foundation,
15 #  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 #
17 # ##### END GPL LICENSE BLOCK #####
18
19 #http://www.pasteall.org/47943/python
20
21 # <pep8-80 compliant>
22
23 import bpy
24 import os
25 import mathutils
26 from mathutils import Vector
27
28 # TODO, make options
29 PREF_SCALE = 1
30 PREF_FACE_THICK = 0.1
31 PREF_GRID_SNAP = False
32 # Quake 1/2?
33 # Quake 3+?
34 PREF_DEF_TEX_OPTS = '0 0 0 1 1 0 0 0'  # not user settable yet
35
36 PREF_NULL_TEX = 'NULL'  # not user settable yet
37 PREF_INVIS_TEX = 'common/caulk'
38 PREF_DOOM3_FORMAT = True
39
40
41 def face_uv_image_get(me, face):
42     uv_faces = me.uv_textures.active
43     if uv_faces:
44         return uv_faces.data[face.index].image
45     else:
46         return None
47
48
49 def face_uv_coords_get(me, face):
50     tf_uv_faces = me.tessface_uv_textures.active
51     if tf_uv_faces:
52         return tf_uv_faces.data[face.index].uv_raw[:]
53     else:
54         return None
55
56
57 def face_material_get(me, face):
58     idx = face.material_index
59     return me.materials[idx] if idx < len(me.materials) else None
60
61
62 def poly_to_doom(me, p, radius):
63     """
64     Convert a face into Doom3 representation (infinite plane defined by its normal
65     and distance from origin along that normal).
66     """
67     # Compute the distance to the mesh from the origin to the plane.
68     # Line from origin in the direction of the face normal.
69     origin = Vector((0, 0, 0))
70     target = Vector(p.normal) * radius
71     # Find the target point.
72     intersect = mathutils.geometry.intersect_line_plane(origin, target, Vector(p.center), Vector(p.normal))
73     # We have to handle cases where intersection with face happens on the "negative" part of the vector!
74     length = intersect.length
75     nor = p.normal.copy()
76     if (nor.dot(intersect.normalized()) > 0):
77         length *= -1
78     nor.resize_4d()
79     nor.w = length
80     return nor
81
82
83 def doom_are_same_planes(p1, p2):
84     """
85     To avoid writing two planes that are nearly the same!
86     """
87     # XXX Is sign of the normal/length important in Doom for plane definition??? For now, assume that no!
88     if p1.w < 0:
89         p1 = p1 * -1.0
90     if p2.w < 0:
91         p2 = p2 * -1.0
92
93     threshold = 0.0001
94
95     if abs(p1.w - p2.w) > threshold:
96         return False
97
98     # Distances are the same, check orientations!
99     if p1.xyz.normalized().dot(p2.xyz.normalized()) < (1 - threshold):
100         return False
101
102     # Same plane!
103     return True
104
105
106 def doom_check_plane(done_planes, plane):
107     """
108     Check if plane as already been handled, or is similar enough to an already handled one.
109     Return True if it has already been handled somehow.
110     done_planes is expected to be a dict {written_plane: {written_plane, similar_plane_1, similar_plane_2, ...}, ...}.
111     """
112     p_key = tuple(plane)
113     if p_key in done_planes:
114         return True
115     for p, dp in done_planes.items():
116         if p_key in dp:
117             return True
118         elif doom_are_same_planes(Vector(p), plane):
119             done_planes[p].add(p_key)
120             return True
121     done_planes[p_key] = {p_key}
122     return False
123
124
125 def ob_to_radius(ob):
126     radius = max(Vector(pt).length for pt in ob.bound_box)
127
128     # Make the ray casts, go just outside the bounding sphere.
129     return radius * 1.1
130
131
132 def is_cube_facegroup(faces):
133     """
134     Returns a bool, true if the faces make up a cube
135     """
136     # cube must have 6 faces
137     if len(faces) != 6:
138         # print('1')
139         return False
140
141     # Check for quads and that there are 6 unique verts
142     verts = {}
143     for f in faces:
144         f_v = f.vertices[:]
145         if len(f_v) != 4:
146             return False
147
148         for v in f_v:
149             verts[v] = 0
150
151     if len(verts) != 8:
152         return False
153
154     # Now check that each vert has 3 face users
155     for f in faces:
156         f_v = f.vertices[:]
157         for v in f_v:
158             verts[v] += 1
159
160     for v in verts.values():
161         if v != 3:  # vert has 3 users?
162             return False
163
164     # Could we check for 12 unique edges??, probably not needed.
165     return True
166
167
168 def is_tricyl_facegroup(faces):
169     """
170     is the face group a tri cylinder
171     Returns a bool, true if the faces make an extruded tri solid
172     """
173
174     # tricyl must have 5 faces
175     if len(faces) != 5:
176         #  print('1')
177         return False
178
179     # Check for quads and that there are 6 unique verts
180     verts = {}
181     tottri = 0
182     for f in faces:
183         if len(f.vertices) == 3:
184             tottri += 1
185
186         for vi in f.vertices:
187             verts[vi] = 0
188
189     if len(verts) != 6 or tottri != 2:
190         return False
191
192     # Now check that each vert has 3 face users
193     for f in faces:
194         for vi in f.vertices:
195             verts[vi] += 1
196
197     for v in verts.values():
198         if v != 3:  # vert has 3 users?
199             return False
200
201     # Could we check for 9 unique edges??, probably not needed.
202     return True
203
204
205 def split_mesh_in_convex_parts(me):
206     """
207     Not implemented yet. Should split given mesh into manifold convex meshes.
208     For now simply always returns the given mesh.
209     """
210     # TODO.
211     return (me,)
212
213
214 def round_vec(v):
215     if PREF_GRID_SNAP:
216         return v.to_tuple(0)
217     else:
218         return v[:]
219
220
221 def write_quake_brush_cube(fw, ob, faces):
222     """
223     Takes 6 faces and writes a brush,
224     these faces can be from 1 mesh, 1 cube within a mesh of larger cubes
225     Faces could even come from different meshes or be contrived.
226     """
227     format_vec = '( %d %d %d ) ' if PREF_GRID_SNAP else '( %.9g %.9g %.9g ) '
228
229     fw('// brush from cube\n{\n')
230
231     for f in faces:
232         # from 4 verts this gets them in reversed order and only 3 of them
233         # 0,1,2,3 -> 2,1,0
234         me = f.id_data  # XXX25
235
236         for v in f.vertices[:][2::-1]:
237             fw(format_vec % round_vec(me.vertices[v].co))
238
239         material = face_material_get(me, f)
240
241         if material and material.game_settings.invisible:
242             fw(PREF_INVIS_TEX)
243         else:
244             image = face_uv_image_get(me, f)
245             if image:
246                 fw(os.path.splitext(bpy.path.basename(image.filepath))[0])
247             else:
248                 fw(PREF_NULL_TEX)
249         fw(" %s\n" % PREF_DEF_TEX_OPTS)  # Texture stuff ignored for now
250
251     fw('}\n')
252
253
254 def write_quake_brush_face(fw, ob, face):
255     """
256     takes a face and writes it as a brush
257     each face is a cube/brush.
258     """
259     format_vec = '( %d %d %d ) ' if PREF_GRID_SNAP else '( %.9g %.9g %.9g ) '
260
261     image_text = PREF_NULL_TEX
262
263     me = face.id_data
264     material = face_material_get(me, face)
265
266     if material and material.game_settings.invisible:
267         image_text = PREF_INVIS_TEX
268     else:
269         image = face_uv_image_get(me, face)
270         if image:
271             image_text = os.path.splitext(bpy.path.basename(image.filepath))[0]
272
273     # reuse face vertices
274     f_vertices = [me.vertices[vi] for vi in face.vertices]
275   
276     # original verts as tuples for writing
277     orig_vco = tuple(round_vec(v.co) for v in f_vertices)
278
279     # new verts that give the face a thickness
280     dist = PREF_SCALE * PREF_FACE_THICK
281     new_vco = tuple(round_vec(v.co - (v.normal * dist)) for v in f_vertices)
282     #new_vco = [round_vec(v.co - (face.no * dist)) for v in face]
283
284     fw('// brush from face\n{\n')
285     # front
286     for co in orig_vco[2::-1]:
287         fw(format_vec % co)
288
289     fw(image_text)
290     fw(" %s\n" % PREF_DEF_TEX_OPTS)  # Texture stuff ignored for now
291
292     for co in new_vco[:3]:
293         fw(format_vec % co)
294     if image and not material.game_settings.use_backface_culling: #uf.use_twoside:
295         fw(image_text)
296     else:
297         fw(PREF_INVIS_TEX)
298     fw(" %s\n" % PREF_DEF_TEX_OPTS)  # Texture stuff ignored for now
299
300     # sides.
301     if len(orig_vco) == 3:  # Tri, it seemms tri brushes are supported.
302         index_pairs = ((0, 1), (1, 2), (2, 0))
303     else:
304         index_pairs = ((0, 1), (1, 2), (2, 3), (3, 0))
305
306     for i1, i2 in index_pairs:
307         for co in orig_vco[i1], orig_vco[i2], new_vco[i2]:
308             fw(format_vec % co)
309         fw(PREF_INVIS_TEX)
310         fw(" %s\n" % PREF_DEF_TEX_OPTS)  # Texture stuff ignored for now
311
312     fw('}\n')
313
314
315 def write_doom_brush(fw, ob, me):
316     """
317     Takes a mesh object and writes its convex parts.
318     """
319     format_vec = '( {} {} {} {} ) '
320     format_vec_uv = '( ( {} {} {} ) ( {} {} {} ) ) '
321     # Get the bounding sphere for the object for ray-casting
322     radius = ob_to_radius(ob)
323
324     fw('// brush from faces\n{\n'
325        'brushDef3\n{\n'
326       )
327
328     done_planes = {}  # Store already written plane, to avoid writing the same one (or a similar-enough one) again.
329
330     for p in me.polygons:
331         image_text = PREF_NULL_TEX
332         material = face_material_get(me, p)
333
334         if material:
335             if material.game_settings.invisible:
336                 image_text = PREF_INVIS_TEX
337             else:
338                 image_text = material.name
339
340         # reuse face vertices
341         plane = poly_to_doom(me, p, radius)    
342         if plane is None:
343             print("    ERROR: Could not create the plane from polygon!");
344         elif doom_check_plane(done_planes, plane):
345             #print("    WARNING: Polygon too similar to another one!");
346             pass
347         else:
348             fw(format_vec.format(*plane.to_tuple(6)))
349             fw(format_vec_uv.format(0.015625, 0, 1, 0, 0.015625, 1)) # TODO insert UV stuff here
350             fw('"%s" ' % image_text)
351             fw("%s\n" % PREF_DEF_TEX_OPTS)  # Texture stuff ignored for now
352
353     fw('}\n}\n')
354
355
356 def write_node_map(fw, ob):
357     """
358     Writes the properties of an object (empty in this case)
359     as a MAP node as long as it has the property name - classname
360     returns True/False based on weather a node was written
361     """
362     props = [(p.name, p.value) for p in ob.game.properties]
363
364     IS_MAP_NODE = False
365     for name, value in props:
366         if name == "classname":
367             IS_MAP_NODE = True
368             break
369
370     if not IS_MAP_NODE:
371         return False
372
373     # Write a node
374     fw('{\n')
375     for name_value in props:
376         fw('"%s" "%s"\n' % name_value)
377     fw('"origin" "%.9g %.9g %.9g"\n' % round_vec(ob.matrix_world.to_translation()))
378
379     fw('}\n')
380     return True
381
382
383 def export_map(context, filepath):
384     """
385     pup_block = [\
386     ('Scale:', PREF_SCALE, 1, 1000,
387             'Scale the blender scene by this value.'),\
388     ('Face Width:', PREF_FACE_THICK, 0.01, 10,
389             'Thickness of faces exported as brushes.'),\
390     ('Grid Snap', PREF_GRID_SNAP,
391             'snaps floating point values to whole numbers.'),\
392     'Null Texture',\
393     ('', PREF_NULL_TEX, 1, 128,
394             'Export textureless faces with this texture'),\
395     'Unseen Texture',\
396     ('', PREF_INVIS_TEX, 1, 128,
397             'Export invisible faces with this texture'),\
398     ]
399
400     if not Draw.PupBlock('map export', pup_block):
401         return
402     """
403     import time
404     from mathutils import Matrix
405     from bpy_extras import mesh_utils
406
407     t = time.time()
408     print("Map Exporter 0.0")
409
410     scene = context.scene
411     objects = context.selected_objects
412
413     obs_mesh = []
414     obs_lamp = []
415     obs_surf = []
416     obs_empty = []
417
418     SCALE_MAT = Matrix()
419     SCALE_MAT[0][0] = SCALE_MAT[1][1] = SCALE_MAT[2][2] = PREF_SCALE
420
421     TOTBRUSH = TOTLAMP = TOTNODE = 0
422
423     for ob in objects:
424         type = ob.type
425         if type == 'MESH':
426             obs_mesh.append(ob)
427         elif type == 'SURFACE':
428             obs_surf.append(ob)
429         elif type == 'LAMP':
430             obs_lamp.append(ob)
431         elif type == 'EMPTY':
432             obs_empty.append(ob)
433
434     with open(filepath, 'w') as fl:
435         fw = fl.write
436
437         if obs_mesh or obs_surf:
438             if PREF_DOOM3_FORMAT:
439                 fw('Version 2')
440             
441             # brushes and surf's must be under worldspan
442             fw('\n// entity 0\n')
443             fw('{\n')
444             fw('"classname" "worldspawn"\n')
445
446         print("\twriting cubes from meshes")
447         for ob in obs_mesh:
448             dummy_mesh = ob.to_mesh(scene, True, 'PREVIEW')
449
450             #print len(mesh_split2connected(dummy_mesh))
451
452             # 1 to tx the normals also
453             dummy_mesh.transform(ob.matrix_world * SCALE_MAT)
454
455             # High quality normals
456             #XXX25: BPyMesh.meshCalcNormals(dummy_mesh)
457
458             if PREF_DOOM3_FORMAT:
459                 for me in split_mesh_in_convex_parts(dummy_mesh):
460                     write_doom_brush(fw, ob, me)
461                     TOTBRUSH += 1
462                     if (me is not dummy_mesh):
463                         bpy.data.meshes.remove(me)
464             else:
465                 # We need tessfaces
466                 dummy_mesh.update(calc_tessface=True)
467                 # Split mesh into connected regions
468                 for face_group in mesh_utils.mesh_linked_tessfaces(dummy_mesh):
469                     if is_cube_facegroup(face_group):
470                         write_quake_brush_cube(fw, ob, face_group)
471                         TOTBRUSH += 1
472                     elif is_tricyl_facegroup(face_group):
473                         write_quake_brush_cube(fw, ob, face_group)
474                         TOTBRUSH += 1
475                     else:
476                         for f in face_group:
477                             write_quake_brush_face(fw, ob, f)
478                             TOTBRUSH += 1
479
480                 #print 'warning, not exporting "%s" it is not a cube' % ob.name
481                 bpy.data.meshes.remove(dummy_mesh)
482
483         valid_dims = 3, 5, 7, 9, 11, 13, 15
484         for ob in obs_surf:
485             '''
486             Surf, patches
487             '''
488             data = ob.data
489             surf_name = data.name
490             mat = ob.matrix_world * SCALE_MAT
491
492             # This is what a valid patch looks like
493             """
494             // brush 0
495             {
496             patchDef2
497             {
498             NULL
499             ( 3 3 0 0 0 )
500             (
501             ( ( -64 -64 0 0 0 ) ( -64 0 0 0 -2 ) ( -64 64 0 0 -4 ) )
502             ( ( 0 -64 0 2 0 ) ( 0 0 0 2 -2 ) ( 0 64 0 2 -4 ) )
503             ( ( 64 -64 0 4 0 ) ( 64 0 0 4 -2 ) ( 80 88 0 4 -4 ) )
504             )
505             }
506             }
507             """
508             for i, nurb in enumerate(data.splines):
509                 u = nurb.point_count_u
510                 v = nurb.point_count_v
511                 if u in valid_dims and v in valid_dims:
512
513                     fw('// brush %d surf_name\n' % i)
514                     fw('{\n')
515                     fw('patchDef2\n')
516                     fw('{\n')
517                     fw('NULL\n')
518                     fw('( %d %d 0 0 0 )\n' % (u, v))
519                     fw('(\n')
520
521                     u_iter = 0
522                     for p in nurb.points:
523
524                         if u_iter == 0:
525                             fw('(')
526
527                         u_iter += 1
528
529                         # add nmapping 0 0 ?
530                         if PREF_GRID_SNAP:
531                             fw(" ( %d %d %d 0 0 )" %
532                                        round_vec(mat * p.co.xyz))
533                         else:
534                             fw(' ( %.6f %.6f %.6f 0 0 )' %
535                                        (mat * p.co.xyz)[:])
536
537                         # Move to next line
538                         if u_iter == u:
539                             fw(' )\n')
540                             u_iter = 0
541
542                     fw(')\n')
543                     fw('}\n')
544                     fw('}\n')
545                     # Debugging
546                     # for p in nurb: print 'patch', p
547
548                 else:
549                     print("Warning: not exporting patch",
550                           surf_name, u, v, 'Unsupported')
551
552         if obs_mesh or obs_surf:
553             fw('}\n')  # end worldspan
554
555         print("\twriting lamps")
556         for ob in obs_lamp:
557             print("\t\t%s" % ob.name)
558             lamp = ob.data
559             fw('{\n')
560             fw('"classname" "light"\n')
561             fw('"light" "%.6f"\n' % (lamp.distance * PREF_SCALE))
562             if PREF_GRID_SNAP:
563                 fw('"origin" "%d %d %d"\n' %
564                            tuple([round(axis * PREF_SCALE)
565                                   for axis in ob.matrix_world.to_translation()]))
566             else:
567                 fw('"origin" "%.6f %.6f %.6f"\n' %
568                            tuple([axis * PREF_SCALE
569                                   for axis in ob.matrix_world.to_translation()]))
570
571             fw('"_color" "%.6f %.6f %.6f"\n' % tuple(lamp.color))
572             fw('"style" "0"\n')
573             fw('}\n')
574             TOTLAMP += 1
575
576         print("\twriting empty objects as nodes")
577         for ob in obs_empty:
578             if write_node_map(fw, ob):
579                 print("\t\t%s" % ob.name)
580                 TOTNODE += 1
581             else:
582                 print("\t\tignoring %s" % ob.name)
583
584     print("Exported Map in %.4fsec" % (time.time() - t))
585     print("Brushes: %d  Nodes: %d  Lamps %d\n" % (TOTBRUSH, TOTNODE, TOTLAMP))
586
587
588 def save(operator,
589          context,
590          filepath=None,
591          global_scale=100.0,
592          face_thickness=0.1,
593          texture_null="NULL",
594          texture_opts='0 0 0 1 1 0 0 0',
595          grid_snap=False,
596          doom3_format=True,
597          ):
598
599     global PREF_SCALE
600     global PREF_FACE_THICK
601     global PREF_NULL_TEX
602     global PREF_DEF_TEX_OPTS
603     global PREF_GRID_SNAP
604     global PREF_DOOM3_FORMAT
605
606     PREF_SCALE = global_scale
607     PREF_FACE_THICK = face_thickness
608     PREF_NULL_TEX = texture_null
609     PREF_DEF_TEX_OPTS = texture_opts
610     PREF_GRID_SNAP = grid_snap
611     PREF_DOOM3_FORMAT = doom3_format
612     
613     if (PREF_DOOM3_FORMAT):
614         PREF_DEF_TEX_OPTS = '0 0 0'
615     else:
616         PREF_DEF_TEX_OPTS = '0 0 0 1 1 0 0 0'
617
618     export_map(context, filepath)
619
620     return {'FINISHED'}