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