Fix T38788: zero area faces raised exception with overhang test
[blender-addons.git] / io_import_images_as_planes.py
blobb3dcb65af25a640bb16d420527704afff0d79338
1 # ##### BEGIN GPL LICENSE BLOCK #####
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.
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.
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.
17 # ##### END GPL LICENSE BLOCK #####
19 bl_info = {
20 "name": "Import Images as Planes",
21 "author": "Florian Meyer (tstscr), mont29, matali",
22 "version": (1, 9, 1),
23 "blender": (2, 69, 0),
24 "location": "File > Import > Images as Planes or Add > Mesh > Images as Planes",
25 "description": "Imports images and creates planes with the appropriate aspect ratio. "
26 "The images are mapped to the planes.",
27 "warning": "",
28 "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Add_Mesh/Planes_from_Images",
29 "tracker_url": "https://developer.blender.org/T21751",
30 "category": "Import-Export"}
32 import bpy
33 from bpy.types import Operator
34 import mathutils
35 import os
36 import collections
38 from bpy.props import (StringProperty,
39 BoolProperty,
40 EnumProperty,
41 IntProperty,
42 FloatProperty,
43 CollectionProperty,
46 from bpy_extras.object_utils import AddObjectHelper, object_data_add
47 from bpy_extras.image_utils import load_image
49 # -----------------------------------------------------------------------------
50 # Global Vars
52 DEFAULT_EXT = "*"
54 EXT_FILTER = getattr(collections, "OrderedDict", dict)((
55 (DEFAULT_EXT, ((), "All image formats", "Import all known image (or movie) formats.")),
56 ("jpeg", (("jpeg", "jpg", "jpe"), "JPEG ({})", "Joint Photographic Experts Group")),
57 ("png", (("png", ), "PNG ({})", "Portable Network Graphics")),
58 ("tga", (("tga", "tpic"), "Truevision TGA ({})", "")),
59 ("tiff", (("tiff", "tif"), "TIFF ({})", "Tagged Image File Format")),
60 ("bmp", (("bmp", "dib"), "BMP ({})", "Windows Bitmap")),
61 ("cin", (("cin", ), "CIN ({})", "")),
62 ("dpx", (("dpx", ), "DPX ({})", "DPX (Digital Picture Exchange)")),
63 ("psd", (("psd", ), "PSD ({})", "Photoshop Document")),
64 ("exr", (("exr", ), "OpenEXR ({})", "OpenEXR HDR imaging image file format")),
65 ("hdr", (("hdr", "pic"), "Radiance HDR ({})", "")),
66 ("avi", (("avi", ), "AVI ({})", "Audio Video Interleave")),
67 ("mov", (("mov", "qt"), "QuickTime ({})", "")),
68 ("mp4", (("mp4", ), "MPEG-4 ({})", "MPEG-4 Part 14")),
69 ("ogg", (("ogg", "ogv"), "OGG Theora ({})", "")),
72 # XXX Hack to avoid allowing videos with Cycles, crashes currently!
73 VID_EXT_FILTER = {e for ext_k, ext_v in EXT_FILTER.items() if ext_k in {"avi", "mov", "mp4", "ogg"} for e in ext_v[0]}
75 CYCLES_SHADERS = (
76 ('BSDF_DIFFUSE', "Diffuse", "Diffuse Shader"),
77 ('EMISSION', "Emission", "Emission Shader"),
78 ('BSDF_DIFFUSE_BSDF_TRANSPARENT', "Diffuse & Transparent", "Diffuse and Transparent Mix"),
79 ('EMISSION_BSDF_TRANSPARENT', "Emission & Transparent", "Emission and Transparent Mix")
82 # -----------------------------------------------------------------------------
83 # Misc utils.
84 def gen_ext_filter_ui_items():
85 return tuple((k, name.format(", ".join("." + e for e in exts)) if "{}" in name else name, desc)
86 for k, (exts, name, desc) in EXT_FILTER.items())
89 def is_image_fn(fn, ext_key):
90 if ext_key == DEFAULT_EXT:
91 return True # Using Blender's image/movie filter.
92 ext = os.path.splitext(fn)[1].lstrip(".").lower()
93 return ext in EXT_FILTER[ext_key][0]
96 # -----------------------------------------------------------------------------
97 # Cycles utils.
98 def get_input_nodes(node, nodes, links):
99 # Get all links going to node.
100 input_links = {lnk for lnk in links if lnk.to_node == node}
101 # Sort those links, get their input nodes (and avoid doubles!).
102 sorted_nodes = []
103 done_nodes = set()
104 for socket in node.inputs:
105 done_links = set()
106 for link in input_links:
107 nd = link.from_node
108 if nd in done_nodes:
109 # Node already treated!
110 done_links.add(link)
111 elif link.to_socket == socket:
112 sorted_nodes.append(nd)
113 done_links.add(link)
114 done_nodes.add(nd)
115 input_links -= done_links
116 return sorted_nodes
119 def auto_align_nodes(node_tree):
120 print('\nAligning Nodes')
121 x_gap = 200
122 y_gap = 100
123 nodes = node_tree.nodes
124 links = node_tree.links
125 to_node = None
126 for node in nodes:
127 if node.type == 'OUTPUT_MATERIAL':
128 to_node = node
129 break
130 if not to_node:
131 return # Unlikely, but bette check anyway...
133 def align(to_node, nodes, links):
134 from_nodes = get_input_nodes(to_node, nodes, links)
135 for i, node in enumerate(from_nodes):
136 node.location.x = to_node.location.x - x_gap
137 node.location.y = to_node.location.y
138 node.location.y -= i * y_gap
139 node.location.y += (len(from_nodes)-1) * y_gap / (len(from_nodes))
140 align(node, nodes, links)
142 align(to_node, nodes, links)
145 def clean_node_tree(node_tree):
146 nodes = node_tree.nodes
147 for node in nodes:
148 if not node.type == 'OUTPUT_MATERIAL':
149 nodes.remove(node)
150 return node_tree.nodes[0]
153 # -----------------------------------------------------------------------------
154 # Operator
156 class IMPORT_OT_image_to_plane(Operator, AddObjectHelper):
157 """Create mesh plane(s) from image files with the appropiate aspect ratio"""
158 bl_idname = "import_image.to_plane"
159 bl_label = "Import Images as Planes"
160 bl_options = {'REGISTER', 'UNDO'}
162 # -----------
163 # File props.
164 files = CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
166 directory = StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
168 # Show only images/videos, and directories!
169 filter_image = BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
170 filter_movie = BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
171 filter_folder = BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
172 filter_glob = StringProperty(default="", options={'HIDDEN', 'SKIP_SAVE'})
174 # --------
175 # Options.
176 align = BoolProperty(name="Align Planes", default=True, description="Create Planes in a row")
178 align_offset = FloatProperty(name="Offset", min=0, soft_min=0, default=0.1, description="Space between Planes")
180 # Callback which will update File window's filter options accordingly to extension setting.
181 def update_extensions(self, context):
182 is_cycles = context.scene.render.engine == 'CYCLES'
183 if self.extension == DEFAULT_EXT:
184 self.filter_image = True
185 # XXX Hack to avoid allowing videos with Cycles, crashes currently!
186 self.filter_movie = True and not is_cycles
187 self.filter_glob = ""
188 else:
189 self.filter_image = False
190 self.filter_movie = False
191 if is_cycles:
192 # XXX Hack to avoid allowing videos with Cycles!
193 flt = ";".join(("*." + e for e in EXT_FILTER[self.extension][0] if e not in VID_EXT_FILTER))
194 else:
195 flt = ";".join(("*." + e for e in EXT_FILTER[self.extension][0]))
196 self.filter_glob = flt
197 # And now update space (file select window), if possible.
198 space = bpy.context.space_data
199 # XXX Can't use direct op comparison, these are not the same objects!
200 if (space.type != 'FILE_BROWSER' or space.operator.bl_rna.identifier != self.bl_rna.identifier):
201 return
202 space.params.use_filter_image = self.filter_image
203 space.params.use_filter_movie = self.filter_movie
204 space.params.filter_glob = self.filter_glob
205 # XXX Seems to be necessary, else not all changes above take effect...
206 bpy.ops.file.refresh()
207 extension = EnumProperty(name="Extension", items=gen_ext_filter_ui_items(),
208 description="Only import files of this type", update=update_extensions)
210 # -------------------
211 # Plane size options.
212 _size_modes = (
213 ('ABSOLUTE', "Absolute", "Use absolute size"),
214 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
215 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
217 size_mode = EnumProperty(name="Size Mode", default='ABSOLUTE', items=_size_modes,
218 description="How the size of the plane is computed")
220 height = FloatProperty(name="Height", description="Height of the created plane",
221 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
223 factor = FloatProperty(name="Definition", min=1.0, default=600.0,
224 description="Number of pixels per inch or Blender Unit")
226 # -------------------------
227 # Blender material options.
228 t = bpy.types.Material.bl_rna.properties["use_shadeless"]
229 use_shadeless = BoolProperty(name=t.name, default=False, description=t.description)
231 use_transparency = BoolProperty(name="Use Alpha", default=False, description="Use alphachannel for transparency")
233 t = bpy.types.Material.bl_rna.properties["transparency_method"]
234 items = tuple((it.identifier, it.name, it.description) for it in t.enum_items)
235 transparency_method = EnumProperty(name="Transp. Method", description=t.description, items=items)
237 t = bpy.types.Material.bl_rna.properties["use_transparent_shadows"]
238 use_transparent_shadows = BoolProperty(name=t.name, default=False, description=t.description)
240 #-------------------------
241 # Cycles material options.
242 shader = EnumProperty(name="Shader", items=CYCLES_SHADERS, description="Node shader to use")
244 overwrite_node_tree = BoolProperty(name="Overwrite Material", default=True,
245 description="Overwrite existing Material with new nodetree "
246 "(based on material name)")
248 # --------------
249 # Image Options.
250 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
251 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
252 alpha_mode = EnumProperty(name=t.name, items=alpha_mode_items, default=t.default, description=t.description)
254 t = bpy.types.IMAGE_OT_match_movie_length.bl_rna
255 match_len = BoolProperty(name=t.name, default=True, description=t.description)
257 t = bpy.types.Image.bl_rna.properties["use_fields"]
258 use_fields = BoolProperty(name=t.name, default=False, description=t.description)
260 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
261 use_auto_refresh = BoolProperty(name=t.name, default=True, description=t.description)
263 relative = BoolProperty(name="Relative", default=True, description="Apply relative paths")
265 def draw(self, context):
266 engine = context.scene.render.engine
267 layout = self.layout
269 box = layout.box()
270 box.label("Import Options:", icon='FILTER')
271 box.prop(self, "extension", icon='FILE_IMAGE')
272 box.prop(self, "align")
273 box.prop(self, "align_offset")
275 row = box.row()
276 row.active = bpy.data.is_saved
277 row.prop(self, "relative")
278 # XXX Hack to avoid allowing videos with Cycles, crashes currently!
279 if engine == 'BLENDER_RENDER':
280 box.prop(self, "match_len")
281 box.prop(self, "use_fields")
282 box.prop(self, "use_auto_refresh")
284 box = layout.box()
285 if engine == 'BLENDER_RENDER':
286 box.label("Material Settings: (Blender)", icon='MATERIAL')
287 box.prop(self, "use_shadeless")
288 box.prop(self, "use_transparency")
289 box.prop(self, "alpha_mode")
290 row = box.row()
291 row.prop(self, "transparency_method", expand=True)
292 box.prop(self, "use_transparent_shadows")
293 elif engine == 'CYCLES':
294 box = layout.box()
295 box.label("Material Settings: (Cycles)", icon='MATERIAL')
296 box.prop(self, 'shader', expand = True)
297 box.prop(self, 'overwrite_node_tree')
299 box = layout.box()
300 box.label("Plane dimensions:", icon='ARROW_LEFTRIGHT')
301 row = box.row()
302 row.prop(self, "size_mode", expand=True)
303 if self.size_mode == 'ABSOLUTE':
304 box.prop(self, "height")
305 else:
306 box.prop(self, "factor")
308 def invoke(self, context, event):
309 self.update_extensions(context)
310 context.window_manager.fileselect_add(self)
311 return {'RUNNING_MODAL'}
313 def execute(self, context):
314 if not bpy.data.is_saved:
315 self.relative = False
317 # the add utils don't work in this case because many objects are added disable relevant things beforehand
318 editmode = context.user_preferences.edit.use_enter_edit_mode
319 context.user_preferences.edit.use_enter_edit_mode = False
320 if context.active_object and context.active_object.mode == 'EDIT':
321 bpy.ops.object.mode_set(mode='OBJECT')
323 self.import_images(context)
325 context.user_preferences.edit.use_enter_edit_mode = editmode
326 return {'FINISHED'}
328 # Main...
329 def import_images(self, context):
330 engine = context.scene.render.engine
331 import_list, directory = self.generate_paths()
333 images = (load_image(path, directory) for path in import_list)
335 if engine in {'BLENDER_RENDER', 'BLENDER_GAME'}:
336 textures = []
337 for img in images:
338 self.set_image_options(img)
339 textures.append(self.create_image_textures(context, img))
341 materials = (self.create_material_for_texture(tex) for tex in textures)
342 elif engine == 'CYCLES':
343 materials = (self.create_cycles_material(img) for img in images)
344 else:
345 return
347 planes = tuple(self.create_image_plane(context, mat) for mat in materials)
349 context.scene.update()
350 if self.align:
351 self.align_planes(planes)
353 for plane in planes:
354 plane.select = True
356 self.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes)))
358 def create_image_plane(self, context, material):
359 engine = context.scene.render.engine
360 if engine in {'BLENDER_RENDER', 'BLENDER_GAME'}:
361 img = material.texture_slots[0].texture.image
362 elif engine == 'CYCLES':
363 nodes = material.node_tree.nodes
364 img = next((node.image for node in nodes if node.type == 'TEX_IMAGE'))
365 px, py = img.size
367 # can't load data
368 if px == 0 or py == 0:
369 px = py = 1
371 if self.size_mode == 'ABSOLUTE':
372 y = self.height
373 x = px / py * y
374 elif self.size_mode == 'DPI':
375 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
376 x = px * fact
377 y = py * fact
378 else: # elif self.size_mode == 'DPBU'
379 fact = 1 / self.factor
380 x = px * fact
381 y = py * fact
383 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
384 plane = context.scene.objects.active
385 # Why does mesh.primitive_plane_add leave the object in edit mode???
386 if plane.mode is not 'OBJECT':
387 bpy.ops.object.mode_set(mode='OBJECT')
388 plane.dimensions = x, y, 0.0
389 plane.name = material.name
390 bpy.ops.object.transform_apply(scale=True)
391 plane.data.uv_textures.new()
392 plane.data.materials.append(material)
393 plane.data.uv_textures[0].data[0].image = img
395 material.game_settings.use_backface_culling = False
396 material.game_settings.alpha_blend = 'ALPHA'
397 return plane
399 def align_planes(self, planes):
400 gap = self.align_offset
401 offset = 0
402 for i, plane in enumerate(planes):
403 offset += (plane.dimensions.x / 2.0) + gap
404 if i == 0:
405 continue
406 move_local = mathutils.Vector((offset, 0.0, 0.0))
407 move_world = plane.location + move_local * plane.matrix_world.inverted()
408 plane.location += move_world
409 offset += (plane.dimensions.x / 2.0)
411 def generate_paths(self):
412 return (fn.name for fn in self.files if is_image_fn(fn.name, self.extension)), self.directory
414 # Internal
415 def create_image_textures(self, context, image):
416 fn_full = os.path.normpath(bpy.path.abspath(image.filepath))
418 # look for texture with importsettings
419 for texture in bpy.data.textures:
420 if texture.type == 'IMAGE':
421 tex_img = texture.image
422 if (tex_img is not None) and (tex_img.library is None):
423 fn_tex_full = os.path.normpath(bpy.path.abspath(tex_img.filepath))
424 if fn_full == fn_tex_full:
425 self.set_texture_options(context, texture)
426 return texture
428 # if no texture is found: create one
429 name_compat = bpy.path.display_name_from_filepath(image.filepath)
430 texture = bpy.data.textures.new(name=name_compat, type='IMAGE')
431 texture.image = image
432 self.set_texture_options(context, texture)
433 return texture
435 def create_material_for_texture(self, texture):
436 # look for material with the needed texture
437 for material in bpy.data.materials:
438 slot = material.texture_slots[0]
439 if slot and slot.texture == texture:
440 self.set_material_options(material, slot)
441 return material
443 # if no material found: create one
444 name_compat = bpy.path.display_name_from_filepath(texture.image.filepath)
445 material = bpy.data.materials.new(name=name_compat)
446 slot = material.texture_slots.add()
447 slot.texture = texture
448 slot.texture_coords = 'UV'
449 self.set_material_options(material, slot)
450 return material
452 def set_image_options(self, image):
453 image.alpha_mode = self.alpha_mode
454 image.use_fields = self.use_fields
456 if self.relative:
457 try: # can't always find the relative path (between drive letters on windows)
458 image.filepath = bpy.path.relpath(image.filepath)
459 except ValueError:
460 pass
462 def set_texture_options(self, context, texture):
463 texture.image.use_alpha = self.use_transparency
464 texture.image_user.use_auto_refresh = self.use_auto_refresh
465 if self.match_len:
466 ctx = context.copy()
467 ctx["edit_image"] = texture.image
468 ctx["edit_image_user"] = texture.image_user
469 bpy.ops.image.match_movie_length(ctx)
471 def set_material_options(self, material, slot):
472 if self.use_transparency:
473 material.alpha = 0.0
474 material.specular_alpha = 0.0
475 slot.use_map_alpha = True
476 else:
477 material.alpha = 1.0
478 material.specular_alpha = 1.0
479 slot.use_map_alpha = False
480 material.use_transparency = self.use_transparency
481 material.transparency_method = self.transparency_method
482 material.use_shadeless = self.use_shadeless
483 material.use_transparent_shadows = self.use_transparent_shadows
485 #--------------------------------------------------------------------------
486 # Cycles
487 def create_cycles_material(self, image):
488 name_compat = bpy.path.display_name_from_filepath(image.filepath)
489 material = None
490 for mat in bpy.data.materials:
491 if mat.name == name_compat and self.overwrite_node_tree:
492 material = mat
493 if not material:
494 material = bpy.data.materials.new(name=name_compat)
496 material.use_nodes = True
497 node_tree = material.node_tree
498 out_node = clean_node_tree(node_tree)
500 if self.shader == 'BSDF_DIFFUSE':
501 bsdf_diffuse = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
502 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
503 tex_image.image = image
504 tex_image.show_texture = True
505 node_tree.links.new(out_node.inputs[0], bsdf_diffuse.outputs[0])
506 node_tree.links.new(bsdf_diffuse.inputs[0], tex_image.outputs[0])
508 elif self.shader == 'EMISSION':
509 emission = node_tree.nodes.new('ShaderNodeEmission')
510 lightpath = node_tree.nodes.new('ShaderNodeLightPath')
511 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
512 tex_image.image = image
513 tex_image.show_texture = True
514 node_tree.links.new(out_node.inputs[0], emission.outputs[0])
515 node_tree.links.new(emission.inputs[0], tex_image.outputs[0])
516 node_tree.links.new(emission.inputs[1], lightpath.outputs[0])
518 elif self.shader == 'BSDF_DIFFUSE_BSDF_TRANSPARENT':
519 bsdf_diffuse = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
520 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
521 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
522 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
523 tex_image.image = image
524 tex_image.show_texture = True
525 node_tree.links.new(out_node.inputs[0], mix_shader.outputs[0])
526 node_tree.links.new(mix_shader.inputs[0], tex_image.outputs[1])
527 node_tree.links.new(mix_shader.inputs[2], bsdf_diffuse.outputs[0])
528 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs[0])
529 node_tree.links.new(bsdf_diffuse.inputs[0], tex_image.outputs[0])
531 elif self.shader == 'EMISSION_BSDF_TRANSPARENT':
532 emission = node_tree.nodes.new('ShaderNodeEmission')
533 lightpath = node_tree.nodes.new('ShaderNodeLightPath')
534 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
535 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
536 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
537 tex_image.image = image
538 tex_image.show_texture = True
539 node_tree.links.new(out_node.inputs[0], mix_shader.outputs[0])
540 node_tree.links.new(mix_shader.inputs[0], tex_image.outputs[1])
541 node_tree.links.new(mix_shader.inputs[2], emission.outputs[0])
542 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs[0])
543 node_tree.links.new(emission.inputs[0], tex_image.outputs[0])
544 node_tree.links.new(emission.inputs[1], lightpath.outputs[0])
546 auto_align_nodes(node_tree)
547 return material
550 # -----------------------------------------------------------------------------
551 # Register
552 def import_images_button(self, context):
553 self.layout.operator(IMPORT_OT_image_to_plane.bl_idname,
554 text="Images as Planes", icon='TEXTURE')
557 def register():
558 bpy.utils.register_module(__name__)
559 bpy.types.INFO_MT_file_import.append(import_images_button)
560 bpy.types.INFO_MT_mesh_add.append(import_images_button)
563 def unregister():
564 bpy.utils.unregister_module(__name__)
565 bpy.types.INFO_MT_file_import.remove(import_images_button)
566 bpy.types.INFO_MT_mesh_add.remove(import_images_button)
569 if __name__ == "__main__":
570 register()