Cleanup: Do not use camel case for "Freestyle"
[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     collection = context.collection
481     objects = context.selected_objects
482
483     obs_mesh = []
484     obs_lamp = []
485     obs_surf = []
486     obs_empty = []
487
488     SCALE_MAT = Matrix()
489     SCALE_MAT[0][0] = SCALE_MAT[1][1] = SCALE_MAT[2][2] = PREF_SCALE
490
491     TOTBRUSH = TOTLAMP = TOTNODE = 0
492
493     for ob in objects:
494         type = ob.type
495         if type == 'MESH':
496             obs_mesh.append(ob)
497         elif type == 'SURFACE':
498             obs_surf.append(ob)
499         elif type == 'LAMP':
500             obs_lamp.append(ob)
501         elif type == 'EMPTY':
502             obs_empty.append(ob)
503
504     obs_mesh = split_objects(context, obs_mesh)
505
506     with open(filepath, 'w') as fl:
507         fw = fl.write
508
509         if obs_mesh or obs_surf:
510             if PREF_DOOM3_FORMAT:
511                 fw('Version 2')
512
513             # brushes and surf's must be under worldspan
514             fw('\n// entity 0\n')
515             fw('{\n')
516             fw('"classname" "worldspawn"\n')
517
518         print("\twriting cubes from meshes")
519
520         tot_ob = len(obs_mesh)
521         for i, ob in enumerate(obs_mesh):
522             print("Exporting object: %d/%d" % (i, tot_ob))
523
524             dummy_mesh = ob.to_mesh(scene, True, 'PREVIEW')
525
526             #print len(mesh_split2connected(dummy_mesh))
527
528             # 1 to tx the normals also
529             dummy_mesh.transform(ob.matrix_world * SCALE_MAT)
530
531             # High quality normals
532             #XXX25: BPyMesh.meshCalcNormals(dummy_mesh)
533
534             if PREF_DOOM3_FORMAT:
535                 for me in split_mesh_in_convex_parts(dummy_mesh):
536                     write_doom_brush(fw, ob, me)
537                     TOTBRUSH += 1
538                     if (me is not dummy_mesh):
539                         bpy.data.meshes.remove(me)
540             else:
541                 # We need tessfaces
542                 dummy_mesh.update(calc_tessface=True)
543                 # Split mesh into connected regions
544                 for face_group in mesh_utils.mesh_linked_tessfaces(dummy_mesh):
545                     if is_cube_facegroup(face_group):
546                         write_quake_brush_cube(fw, ob, face_group)
547                         TOTBRUSH += 1
548                     elif is_tricyl_facegroup(face_group):
549                         write_quake_brush_cube(fw, ob, face_group)
550                         TOTBRUSH += 1
551                     else:
552                         for f in face_group:
553                             write_quake_brush_face(fw, ob, f)
554                             TOTBRUSH += 1
555
556                 #print 'warning, not exporting "%s" it is not a cube' % ob.name
557                 bpy.data.meshes.remove(dummy_mesh)
558
559         valid_dims = 3, 5, 7, 9, 11, 13, 15
560         for ob in obs_surf:
561             '''
562             Surf, patches
563             '''
564             data = ob.data
565             surf_name = data.name
566             mat = ob.matrix_world * SCALE_MAT
567
568             # This is what a valid patch looks like
569             """
570             // brush 0
571             {
572             patchDef2
573             {
574             NULL
575             ( 3 3 0 0 0 )
576             (
577             ( ( -64 -64 0 0 0 ) ( -64 0 0 0 -2 ) ( -64 64 0 0 -4 ) )
578             ( ( 0 -64 0 2 0 ) ( 0 0 0 2 -2 ) ( 0 64 0 2 -4 ) )
579             ( ( 64 -64 0 4 0 ) ( 64 0 0 4 -2 ) ( 80 88 0 4 -4 ) )
580             )
581             }
582             }
583             """
584             for i, nurb in enumerate(data.splines):
585                 u = nurb.point_count_u
586                 v = nurb.point_count_v
587                 if u in valid_dims and v in valid_dims:
588
589                     fw('// brush %d surf_name\n' % i)
590                     fw('{\n')
591                     fw('patchDef2\n')
592                     fw('{\n')
593                     fw('NULL\n')
594                     fw('( %d %d 0 0 0 )\n' % (u, v))
595                     fw('(\n')
596
597                     u_iter = 0
598                     for p in nurb.points:
599
600                         if u_iter == 0:
601                             fw('(')
602
603                         u_iter += 1
604
605                         # add nmapping 0 0 ?
606                         if PREF_GRID_SNAP:
607                             fw(" ( %d %d %d 0 0 )" %
608                                        round_vec(mat * p.co.xyz))
609                         else:
610                             fw(' ( %.6f %.6f %.6f 0 0 )' %
611                                        (mat * p.co.xyz)[:])
612
613                         # Move to next line
614                         if u_iter == u:
615                             fw(' )\n')
616                             u_iter = 0
617
618                     fw(')\n')
619                     fw('}\n')
620                     fw('}\n')
621                     # Debugging
622                     # for p in nurb: print 'patch', p
623
624                 else:
625                     print("Warning: not exporting patch",
626                           surf_name, u, v, 'Unsupported')
627
628         if obs_mesh or obs_surf:
629             fw('}\n')  # end worldspan
630
631         print("\twriting lamps")
632         for ob in obs_lamp:
633             print("\t\t%s" % ob.name)
634             lamp = ob.data
635             fw('{\n')
636             fw('"classname" "light"\n')
637             fw('"light" "%.6f"\n' % (lamp.distance * PREF_SCALE))
638             if PREF_GRID_SNAP:
639                 fw('"origin" "%d %d %d"\n' %
640                            tuple([round(axis * PREF_SCALE)
641                                   for axis in ob.matrix_world.to_translation()]))
642             else:
643                 fw('"origin" "%.6f %.6f %.6f"\n' %
644                            tuple([axis * PREF_SCALE
645                                   for axis in ob.matrix_world.to_translation()]))
646
647             fw('"_color" "%.6f %.6f %.6f"\n' % tuple(lamp.color))
648             fw('"style" "0"\n')
649             fw('}\n')
650             TOTLAMP += 1
651
652         print("\twriting empty objects as nodes")
653         for ob in obs_empty:
654             if write_node_map(fw, ob):
655                 print("\t\t%s" % ob.name)
656                 TOTNODE += 1
657             else:
658                 print("\t\tignoring %s" % ob.name)
659
660     for ob in obs_mesh:
661         collection.objects.unlink(ob)
662         bpy.data.objects.remove(ob)
663
664     print("Exported Map in %.4fsec" % (time.time() - t))
665     print("Brushes: %d  Nodes: %d  Lamps %d\n" % (TOTBRUSH, TOTNODE, TOTLAMP))
666
667
668 def save(operator,
669          context,
670          filepath=None,
671          global_scale=1.0,
672          face_thickness=0.1,
673          texture_null="NULL",
674          texture_opts='0 0 0 1 1 0 0 0',
675          grid_snap=False,
676          doom3_format=True,
677          ):
678
679     global PREF_SCALE
680     global PREF_FACE_THICK
681     global PREF_NULL_TEX
682     global PREF_DEF_TEX_OPTS
683     global PREF_GRID_SNAP
684     global PREF_DOOM3_FORMAT
685
686     PREF_SCALE = global_scale
687     PREF_FACE_THICK = face_thickness
688     PREF_NULL_TEX = texture_null
689     PREF_DEF_TEX_OPTS = texture_opts
690     PREF_GRID_SNAP = grid_snap
691     PREF_DOOM3_FORMAT = doom3_format
692
693     if (PREF_DOOM3_FORMAT):
694         PREF_DEF_TEX_OPTS = '0 0 0'
695     else:
696         PREF_DEF_TEX_OPTS = '0 0 0 1 1 0 0 0'
697
698     export_map(context, filepath)
699
700     return {'FINISHED'}