io_mesh_uv_layout: count no longer a keyword only arg
[blender-addons.git] / io_import_images_as_planes.py
blob4ff123df74c1c3f86754784c858317aa6534f647
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 # <pep8 compliant>
21 bl_info = {
22 "name": "Import Images as Planes",
23 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc)",
24 "version": (3, 2, 0),
25 "blender": (2, 80, 0),
26 "location": "File > Import > Images as Planes or Add > Mesh > Images as Planes",
27 "description": "Imports images and creates planes with the appropriate aspect ratio. "
28 "The images are mapped to the planes.",
29 "warning": "",
30 "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
31 "Scripts/Add_Mesh/Planes_from_Images",
32 "category": "Import-Export",
35 import os
36 import warnings
37 import re
38 from itertools import count, repeat
39 from collections import namedtuple
40 from math import pi
42 import bpy
43 from bpy.types import Operator
44 from mathutils import Vector
46 from bpy.props import (
47 StringProperty,
48 BoolProperty,
49 EnumProperty,
50 FloatProperty,
51 CollectionProperty,
54 from bpy_extras.object_utils import (
55 AddObjectHelper,
56 world_to_camera_view,
59 from bpy_extras.image_utils import load_image
61 # -----------------------------------------------------------------------------
62 # Module-level Shared State
64 watched_objects = {} # used to trigger compositor updates on scene updates
67 # -----------------------------------------------------------------------------
68 # Misc utils.
70 def add_driver_prop(driver, name, type, id, path):
71 """Configure a new driver variable."""
72 dv = driver.variables.new()
73 dv.name = name
74 dv.type = 'SINGLE_PROP'
75 target = dv.targets[0]
76 target.id_type = type
77 target.id = id
78 target.data_path = path
81 # -----------------------------------------------------------------------------
82 # Image loading
84 ImageSpec = namedtuple(
85 'ImageSpec',
86 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
88 num_regex = re.compile('[0-9]') # Find a single number
89 nums_regex = re.compile('[0-9]+') # Find a set of numbers
92 def find_image_sequences(files):
93 """From a group of files, detect image sequences.
95 This returns a generator of tuples, which contain the filename,
96 start frame, and length of the detected sequence
98 >>> list(find_image_sequences([
99 ... "test2-001.jp2", "test2-002.jp2",
100 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
101 ... "blaah"]))
102 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
105 files = iter(sorted(files))
106 prev_file = None
107 pattern = ""
108 matches = []
109 segment = None
110 length = 1
111 for filename in files:
112 new_pattern = num_regex.sub('#', filename)
113 new_matches = list(map(int, nums_regex.findall(filename)))
114 if new_pattern == pattern:
115 # this file looks like it may be in sequence from the previous
117 # if there are multiple sets of numbers, figure out what changed
118 if segment is None:
119 for i, prev, cur in zip(count(), matches, new_matches):
120 if prev != cur:
121 segment = i
122 break
124 # did it only change by one?
125 for i, prev, cur in zip(count(), matches, new_matches):
126 if i == segment:
127 # We expect this to increment
128 prev = prev + length
129 if prev != cur:
130 break
132 # All good!
133 else:
134 length += 1
135 continue
137 # No continuation -> spit out what we found and reset counters
138 if prev_file:
139 if length > 1:
140 yield prev_file, matches[segment], length
141 else:
142 yield prev_file, 1, 1
144 prev_file = filename
145 matches = new_matches
146 pattern = new_pattern
147 segment = None
148 length = 1
150 if prev_file:
151 if length > 1:
152 yield prev_file, matches[segment], length
153 else:
154 yield prev_file, 1, 1
157 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
158 """Wrapper for bpy's load_image
160 Loads a set of images, movies, or even image sequences
161 Returns a generator of ImageSpec wrapper objects later used for texture setup
163 if find_sequences: # if finding sequences, we need some pre-processing first
164 file_iter = find_image_sequences(filenames)
165 else:
166 file_iter = zip(filenames, repeat(1), repeat(1))
168 for filename, offset, frames in file_iter:
169 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
171 # Size is unavailable for sequences, so we grab it early
172 size = tuple(image.size)
174 if image.source == 'MOVIE':
175 # Blender BPY BUG!
176 # This number is only valid when read a second time in 2.77
177 # This repeated line is not a mistake
178 frames = image.frame_duration
179 frames = image.frame_duration
181 elif frames > 1: # Not movie, but multiple frames -> image sequence
182 image.source = 'SEQUENCE'
184 yield ImageSpec(image, size, frame_start, offset - 1, frames)
187 # -----------------------------------------------------------------------------
188 # Position & Size Helpers
190 def offset_planes(planes, gap, axis):
191 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
193 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
194 obj2 0.5 blender units away from obj1 along the local positive Z axis.
196 This is in local space, not world space, so all planes should share
197 a common scale and rotation.
199 prior = planes[0]
200 offset = Vector()
201 for current in planes[1:]:
202 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
204 offset += local_offset * axis
205 current.location = current.matrix_world @ offset
207 prior = current
210 def compute_camera_size(context, center, fill_mode, aspect):
211 """Determine how large an object needs to be to fit or fill the camera's field of view."""
212 scene = context.scene
213 camera = scene.camera
214 view_frame = camera.data.view_frame(scene=scene)
215 frame_size = \
216 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
217 Vector([min(v[i] for v in view_frame) for i in range(3)])
218 camera_aspect = frame_size.x / frame_size.y
220 # Convert the frame size to the correct sizing at a given distance
221 if camera.type == 'ORTHO':
222 frame_size = frame_size.xy
223 else:
224 # Perspective transform
225 distance = world_to_camera_view(scene, camera, center).z
226 frame_size = distance * frame_size.xy / (-view_frame[0].z)
228 # Determine what axis to match to the camera
229 match_axis = 0 # match the Y axis size
230 match_aspect = aspect
231 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
232 (fill_mode == 'FIT' and aspect < camera_aspect):
233 match_axis = 1 # match the X axis size
234 match_aspect = 1.0 / aspect
236 # scale the other axis to the correct aspect
237 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
239 return frame_size
242 def center_in_camera(scene, camera, obj, axis=(1, 1)):
243 """Center object along specified axis of the camera"""
244 camera_matrix_col = camera.matrix_world.col
245 location = obj.location
247 # Vector from the camera's world coordinate center to the object's center
248 delta = camera_matrix_col[3].xyz - location
250 # How far off center we are along the camera's local X
251 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
252 # How far off center we are along the camera's local Y
253 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
255 # Now offset only along camera local axis
256 offset = camera_matrix_col[0].xyz * camera_x_mag + \
257 camera_matrix_col[1].xyz * camera_y_mag
259 obj.location = location + offset
262 # -----------------------------------------------------------------------------
263 # Cycles/Eevee utils
265 def get_input_nodes(node, links):
266 """Get nodes that are a inputs to the given node"""
267 # Get all links going to node.
268 input_links = {lnk for lnk in links if lnk.to_node == node}
269 # Sort those links, get their input nodes (and avoid doubles!).
270 sorted_nodes = []
271 done_nodes = set()
272 for socket in node.inputs:
273 done_links = set()
274 for link in input_links:
275 nd = link.from_node
276 if nd in done_nodes:
277 # Node already treated!
278 done_links.add(link)
279 elif link.to_socket == socket:
280 sorted_nodes.append(nd)
281 done_links.add(link)
282 done_nodes.add(nd)
283 input_links -= done_links
284 return sorted_nodes
287 def auto_align_nodes(node_tree):
288 """Given a shader node tree, arrange nodes neatly relative to the output node."""
289 x_gap = 200
290 y_gap = 180
291 nodes = node_tree.nodes
292 links = node_tree.links
293 output_node = None
294 for node in nodes:
295 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
296 output_node = node
297 break
299 else: # Just in case there is no output
300 return
302 def align(to_node):
303 from_nodes = get_input_nodes(to_node, links)
304 for i, node in enumerate(from_nodes):
305 node.location.x = min(node.location.x, to_node.location.x - x_gap)
306 node.location.y = to_node.location.y
307 node.location.y -= i * y_gap
308 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
309 align(node)
311 align(output_node)
314 def clean_node_tree(node_tree):
315 """Clear all nodes in a shader node tree except the output.
317 Returns the output node
319 nodes = node_tree.nodes
320 for node in list(nodes): # copy to avoid altering the loop's data source
321 if not node.type == 'OUTPUT_MATERIAL':
322 nodes.remove(node)
324 return node_tree.nodes[0]
327 def get_shadeless_node(dest_node_tree):
328 """Return a "shadless" cycles/eevee node, creating a node group if nonexistant"""
329 try:
330 node_tree = bpy.data.node_groups['IAP_SHADELESS']
332 except KeyError:
333 # need to build node shadeless node group
334 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
335 output_node = node_tree.nodes.new('NodeGroupOutput')
336 input_node = node_tree.nodes.new('NodeGroupInput')
338 node_tree.outputs.new('NodeSocketShader', 'Shader')
339 node_tree.inputs.new('NodeSocketColor', 'Color')
341 # This could be faster as a transparent shader, but then no ambient occlusion
342 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
343 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
345 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
346 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
348 light_path = node_tree.nodes.new('ShaderNodeLightPath')
349 is_glossy_ray = light_path.outputs['Is Glossy Ray']
350 is_shadow_ray = light_path.outputs['Is Shadow Ray']
351 ray_depth = light_path.outputs['Ray Depth']
352 transmission_depth = light_path.outputs['Transmission Depth']
354 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
355 unrefracted_depth.operation = 'SUBTRACT'
356 unrefracted_depth.label = 'Bounce Count'
357 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
358 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
360 refracted = node_tree.nodes.new('ShaderNodeMath')
361 refracted.operation = 'SUBTRACT'
362 refracted.label = 'Camera or Refracted'
363 refracted.inputs[0].default_value = 1.0
364 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
366 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
367 reflection_limit.operation = 'SUBTRACT'
368 reflection_limit.label = 'Limit Reflections'
369 reflection_limit.inputs[0].default_value = 2.0
370 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
372 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
373 camera_reflected.operation = 'MULTIPLY'
374 camera_reflected.label = 'Camera Ray to Glossy'
375 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
376 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
378 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
379 shadow_or_reflect.operation = 'MAXIMUM'
380 shadow_or_reflect.label = 'Shadow or Reflection?'
381 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
382 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
384 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
385 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
386 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
387 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
388 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
390 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
391 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
392 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
393 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
395 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
397 auto_align_nodes(node_tree)
399 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
400 group_node.node_tree = node_tree
402 return group_node
405 # -----------------------------------------------------------------------------
406 # Corner Pin Driver Helpers
408 @bpy.app.handlers.persistent
409 def check_drivers(*args, **kwargs):
410 """Check if watched objects in a scene have changed and trigger compositor update
412 This is part of a hack to ensure the compositor updates
413 itself when the objects used for drivers change.
415 It only triggers if transformation matricies change to avoid
416 a cyclic loop of updates.
418 if not watched_objects:
419 # if there is nothing to watch, don't bother running this
420 bpy.app.handlers.scene_update_post.remove(check_drivers)
421 return
423 update = False
424 for name, matrix in list(watched_objects.items()):
425 try:
426 obj = bpy.data.objects[name]
427 except KeyError:
428 # The user must have removed this object
429 del watched_objects[name]
430 else:
431 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
432 if new_matrix != matrix:
433 watched_objects[name] = new_matrix
434 update = True
436 if update:
437 # Trick to re-evaluate drivers
438 bpy.context.scene.frame_current = bpy.context.scene.frame_current
441 def register_watched_object(obj):
442 """Register an object to be monitored for transformation changes"""
443 name = obj.name
445 # known object? -> we're done
446 if name in watched_objects:
447 return
449 if not watched_objects:
450 # make sure check_drivers is active
451 bpy.app.handlers.scene_update_post.append(check_drivers)
453 watched_objects[name] = None
456 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
457 """Find the location in camera space of a plane's corner"""
458 if args or kwargs:
459 # I've added args / kwargs as a compatability measure with future versions
460 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
462 plane = bpy.data.objects[object_name]
464 # Passing in camera doesn't work before 2.78, so we use the current one
465 camera = camera or bpy.context.scene.camera
467 # Hack to ensure compositor updates on future changes
468 register_watched_object(camera)
469 register_watched_object(plane)
471 scale = plane.scale * 2.0
472 v = plane.dimensions.copy()
473 v.x *= x / scale.x
474 v.y *= y / scale.y
475 v = plane.matrix_world @ v
477 camera_vertex = world_to_camera_view(
478 bpy.context.scene, camera, v)
480 return camera_vertex[axis]
483 @bpy.app.handlers.persistent
484 def register_driver(*args, **kwargs):
485 """Register the find_plane_corner function for use with drivers"""
486 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
489 # -----------------------------------------------------------------------------
490 # Compositing Helpers
492 def group_in_frame(node_tree, name, nodes):
493 frame_node = node_tree.nodes.new("NodeFrame")
494 frame_node.label = name
495 frame_node.name = name + "_frame"
497 min_pos = Vector(nodes[0].location)
498 max_pos = min_pos.copy()
500 for node in nodes:
501 top_left = node.location
502 bottom_right = top_left + Vector((node.width, -node.height))
504 for i in (0, 1):
505 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
506 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
508 node.parent = frame_node
510 frame_node.width = max_pos[0] - min_pos[0] + 50
511 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
512 frame_node.shrink = True
514 return frame_node
517 def position_frame_bottom_left(node_tree, frame_node):
518 newpos = Vector((100000, 100000)) # start reasonably far top / right
520 # Align with the furthest left
521 for node in node_tree.nodes.values():
522 if node != frame_node and node.parent != frame_node:
523 newpos.x = min(newpos.x, node.location.x + 30)
525 # As high as we can get without overlapping anything to the right
526 for node in node_tree.nodes.values():
527 if node != frame_node and not node.parent:
528 if node.location.x < newpos.x + frame_node.width:
529 print("Below", node.name, node.location, node.height, node.dimensions)
530 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
532 frame_node.location = newpos
535 def setup_compositing(context, plane, img_spec):
536 # Node Groups only work with "new" dependency graph and even
537 # then it has some problems with not updating the first time
538 # So instead this groups with a node frame, which works reliably
540 scene = context.scene
541 scene.use_nodes = True
542 node_tree = scene.node_tree
543 name = plane.name
545 image_node = node_tree.nodes.new("CompositorNodeImage")
546 image_node.name = name + "_image"
547 image_node.image = img_spec.image
548 image_node.location = Vector((0, 0))
549 image_node.frame_start = img_spec.frame_start
550 image_node.frame_offset = img_spec.frame_offset
551 image_node.frame_duration = img_spec.frame_duration
553 scale_node = node_tree.nodes.new("CompositorNodeScale")
554 scale_node.name = name + "_scale"
555 scale_node.space = 'RENDER_SIZE'
556 scale_node.location = image_node.location + \
557 Vector((image_node.width + 20, 0))
558 scale_node.show_options = False
560 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
561 cornerpin_node.name = name + "_cornerpin"
562 cornerpin_node.location = scale_node.location + \
563 Vector((0, -scale_node.height))
565 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
566 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
568 # Put all the nodes in a frame for organization
569 frame_node = group_in_frame(
570 node_tree, name,
571 (image_node, scale_node, cornerpin_node)
574 # Position frame at bottom / left
575 position_frame_bottom_left(node_tree, frame_node)
577 # Configure Drivers
578 for corner in cornerpin_node.inputs[1:]:
579 id = corner.identifier
580 x = -1 if 'Left' in id else 1
581 y = -1 if 'Lower' in id else 1
582 drivers = corner.driver_add('default_value')
583 for i, axis_fcurve in enumerate(drivers):
584 driver = axis_fcurve.driver
585 # Always use the current camera
586 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
587 # Track camera location to ensure Deps Graph triggers (not used in the call)
588 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
589 # Don't break if the name changes
590 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
591 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
592 repr(plane.name),
593 x, y, i
595 driver.type = 'SCRIPTED'
596 driver.is_valid = True
597 axis_fcurve.is_valid = True
598 driver.expression = "%s" % driver.expression
600 scene.update()
603 # -----------------------------------------------------------------------------
604 # Operator
606 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
607 """Create mesh plane(s) from image files with the appropiate aspect ratio"""
609 bl_idname = "import_image.to_plane"
610 bl_label = "Import Images as Planes"
611 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
613 # ----------------------
614 # File dialog properties
615 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
617 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
619 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
620 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
621 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
623 # ----------------------
624 # Properties - Importing
625 force_reload: BoolProperty(
626 name="Force Reload", default=False,
627 description="Force reloading of the image if already opened elsewhere in Blender"
630 image_sequence: BoolProperty(
631 name="Animate Image Sequences", default=False,
632 description="Import sequentially numbered images as an animated "
633 "image sequence instead of separate planes"
636 # -------------------------------------
637 # Properties - Position and Orientation
638 axis_id_to_vector = {
639 'X+': Vector(( 1, 0, 0)),
640 'Y+': Vector(( 0, 1, 0)),
641 'Z+': Vector(( 0, 0, 1)),
642 'X-': Vector((-1, 0, 0)),
643 'Y-': Vector(( 0, -1, 0)),
644 'Z-': Vector(( 0, 0, -1)),
647 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
649 OFFSET_MODES = (
650 ('X+', "X+", "Side by Side to the Left"),
651 ('Y+', "Y+", "Side by Side, Downward"),
652 ('Z+', "Z+", "Stacked Above"),
653 ('X-', "X-", "Side by Side to the Right"),
654 ('Y-', "Y-", "Side by Side, Upward"),
655 ('Z-', "Z-", "Stacked Below"),
657 offset_axis: EnumProperty(
658 name="Orientation", default='X+', items=OFFSET_MODES,
659 description="How planes are oriented relative to each others' local axis"
662 offset_amount: FloatProperty(
663 name="Offset", soft_min=0, default=0.1, description="Space between planes",
664 subtype='DISTANCE', unit='LENGTH'
667 AXIS_MODES = (
668 ('X+', "X+", "Facing Positive X"),
669 ('Y+', "Y+", "Facing Positive Y"),
670 ('Z+', "Z+ (Up)", "Facing Positive Z"),
671 ('X-', "X-", "Facing Negative X"),
672 ('Y-', "Y-", "Facing Negative Y"),
673 ('Z-', "Z- (Down)", "Facing Negative Z"),
674 ('CAM', "Face Camera", "Facing Camera"),
675 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
677 align_axis: EnumProperty(
678 name="Align", default='CAM_AX', items=AXIS_MODES,
679 description="How to align the planes"
681 # prev_align_axis is used only by update_size_model
682 prev_align_axis: EnumProperty(
683 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
684 align_track: BoolProperty(
685 name="Track Camera", default=False, description="Always face the camera"
688 # -----------------
689 # Properties - Size
690 def update_size_mode(self, context):
691 """If sizing relative to the camera, always face the camera"""
692 if self.size_mode == 'CAMERA':
693 self.prev_align_axis = self.align_axis
694 self.align_axis = 'CAM'
695 else:
696 # if a different alignment was set revert to that when
697 # size mode is changed
698 if self.prev_align_axis != 'NONE':
699 self.align_axis = self.prev_align_axis
700 self._prev_align_axis = 'NONE'
702 SIZE_MODES = (
703 ('ABSOLUTE', "Absolute", "Use absolute size"),
704 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
705 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
706 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
708 size_mode: EnumProperty(
709 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
710 update=update_size_mode,
711 description="How the size of the plane is computed")
713 FILL_MODES = (
714 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
715 ('FIT', "Fit", "Fit entire image within the camera frame"),
717 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
718 description="How large in the camera frame is the plane")
720 height: FloatProperty(name="Height", description="Height of the created plane",
721 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
723 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
724 description="Number of pixels per inch or Blender Unit")
726 # ------------------------------
727 # Properties - Material / Shader
728 SHADERS = (
729 ('DIFFUSE', "Diffuse", "Diffuse Shader"),
730 ('SHADELESS', "Shadeless", "Only visible to camera and reflections."),
731 ('EMISSION', "Emit", "Emission Shader"),
733 shader: EnumProperty(name="Shader", items=SHADERS, default='DIFFUSE', description="Node shader to use")
735 emit_strength: FloatProperty(
736 name="Strength", min=0.0, default=1.0, soft_max=10.0,
737 step=100, description="Brightness of Emission Texture")
739 overwrite_material: BoolProperty(
740 name="Overwrite Material", default=True,
741 description="Overwrite existing Material (based on material name)")
743 compositing_nodes: BoolProperty(
744 name="Setup Corner Pin", default=False,
745 description="Build Compositor Nodes to reference this image "
746 "without re-rendering")
748 # ------------------
749 # Properties - Image
750 use_transparency: BoolProperty(
751 name="Use Alpha", default=True,
752 description="Use alphachannel for transparency")
754 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
755 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
756 alpha_mode: EnumProperty(
757 name=t.name, items=alpha_mode_items, default=t.default,
758 description=t.description)
760 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
761 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
763 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
765 # -------
766 # Draw UI
767 def draw_import_config(self, context):
768 # --- Import Options --- #
769 layout = self.layout
770 box = layout.box()
772 box.label(text="Import Options:", icon='IMPORT')
773 row = box.row()
774 row.active = bpy.data.is_saved
775 row.prop(self, "relative")
777 box.prop(self, "force_reload")
778 box.prop(self, "image_sequence")
780 def draw_material_config(self, context):
781 # --- Material / Rendering Properties --- #
782 layout = self.layout
783 box = layout.box()
785 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
786 box.prop(self, "compositing_nodes")
788 box.label(text="Material Settings:", icon='MATERIAL')
790 row = box.row()
791 row.prop(self, 'shader', expand=True)
792 if self.shader == 'EMISSION':
793 box.prop(self, "emit_strength")
795 engine = context.scene.render.engine
796 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_OPENGL'):
797 box.label(text="%s is not supported" % engine, icon='ERROR')
799 box.prop(self, "overwrite_material")
801 box.label(text="Texture Settings:", icon='TEXTURE')
802 row = box.row()
803 row.prop(self, "use_transparency")
804 sub = row.row()
805 sub.active = self.use_transparency
806 sub.prop(self, "alpha_mode", text="")
807 box.prop(self, "use_auto_refresh")
809 def draw_spatial_config(self, context):
810 # --- Spatial Properties: Position, Size and Orientation --- #
811 layout = self.layout
812 box = layout.box()
814 box.label(text="Position:", icon='SNAP_GRID')
815 box.prop(self, "offset")
816 col = box.column()
817 row = col.row()
818 row.prop(self, "offset_axis", expand=True)
819 row = col.row()
820 row.prop(self, "offset_amount")
821 col.enabled = self.offset
823 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
824 row = box.row()
825 row.prop(self, "size_mode", expand=True)
826 if self.size_mode == 'ABSOLUTE':
827 box.prop(self, "height")
828 elif self.size_mode == 'CAMERA':
829 row = box.row()
830 row.prop(self, "fill_mode", expand=True)
831 else:
832 box.prop(self, "factor")
834 box.label(text="Orientation:", icon='MANIPUL')
835 row = box.row()
836 row.enabled = 'CAM' not in self.size_mode
837 row.prop(self, "align_axis")
838 row = box.row()
839 row.enabled = 'CAM' in self.align_axis
840 row.alignment = 'RIGHT'
841 row.prop(self, "align_track")
843 def draw(self, context):
845 # Draw configuration sections
846 self.draw_import_config(context)
847 self.draw_material_config(context)
848 self.draw_spatial_config(context)
850 # -------------------------------------------------------------------------
851 # Core functionality
852 def invoke(self, context, event):
853 engine = context.scene.render.engine
854 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
855 if engine not in {'BLENDER_OPENGL'}:
856 self.report({'ERROR'}, "Cannot generate materials for unknown %s render engine" % engine)
857 return {'CANCELLED'}
858 else:
859 self.report({'WARNING'},
860 "Generating Cycles/EEVEE compatible material, but won't be visible with %s engine" % engine)
862 # Open file browser
863 context.window_manager.fileselect_add(self)
864 return {'RUNNING_MODAL'}
866 def execute(self, context):
867 if not bpy.data.is_saved:
868 self.relative = False
870 # this won't work in edit mode
871 editmode = context.user_preferences.edit.use_enter_edit_mode
872 context.user_preferences.edit.use_enter_edit_mode = False
873 if context.active_object and context.active_object.mode == 'EDIT':
874 bpy.ops.object.mode_set(mode='OBJECT')
876 self.import_images(context)
878 context.user_preferences.edit.use_enter_edit_mode = editmode
880 return {'FINISHED'}
882 def import_images(self, context):
884 # load images / sequences
885 images = tuple(load_images(
886 (fn.name for fn in self.files),
887 self.directory,
888 force_reload=self.force_reload,
889 find_sequences=self.image_sequence
892 # Create individual planes
893 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
895 context.scene.update()
897 # Align planes relative to each other
898 if self.offset:
899 offset_axis = self.axis_id_to_vector[self.offset_axis]
900 offset_planes(planes, self.offset_amount, offset_axis)
902 if self.size_mode == 'CAMERA' and offset_axis.z:
903 for plane in planes:
904 x, y = compute_camera_size(
905 context, plane.location,
906 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
907 plane.dimensions = x, y, 0.0
909 # setup new selection
910 for plane in planes:
911 plane.select_set('SELECT')
913 # all done!
914 self.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes)))
916 # operate on a single image
917 def single_image_spec_to_plane(self, context, img_spec):
919 # Configure image
920 self.apply_image_options(img_spec.image)
922 # Configure material
923 engine = context.scene.render.engine
924 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_OPENGL'}:
925 material = self.create_cycles_material(context, img_spec)
927 # Create and position plane object
928 plane = self.create_image_plane(context, material.name, img_spec)
930 # Assign Material
931 plane.data.materials.append(material)
933 # If applicable, setup Corner Pin node
934 if self.compositing_nodes:
935 setup_compositing(context, plane, img_spec)
937 return plane
939 def apply_image_options(self, image):
940 image.use_alpha = self.use_transparency
941 image.alpha_mode = self.alpha_mode
943 if self.relative:
944 try: # can't always find the relative path (between drive letters on windows)
945 image.filepath = bpy.path.relpath(image.filepath)
946 except ValueError:
947 pass
949 def apply_texture_options(self, texture, img_spec):
950 # Shared by both Cycles and Blender Internal
951 image_user = texture.image_user
952 image_user.use_auto_refresh = self.use_auto_refresh
953 image_user.frame_start = img_spec.frame_start
954 image_user.frame_offset = img_spec.frame_offset
955 image_user.frame_duration = img_spec.frame_duration
957 # Image sequences need auto refresh to display reliably
958 if img_spec.image.source == 'SEQUENCE':
959 image_user.use_auto_refresh = True
961 texture.extension = 'CLIP' # Default of "Repeat" can cause artifacts
963 def apply_material_options(self, material, slot):
964 shader = self.shader
966 if self.use_transparency:
967 material.alpha = 0.0
968 material.specular_alpha = 0.0
969 slot.use_map_alpha = True
970 else:
971 material.alpha = 1.0
972 material.specular_alpha = 1.0
973 slot.use_map_alpha = False
975 material.specular_intensity = 0
976 material.diffuse_intensity = 1.0
977 material.use_transparency = self.use_transparency
978 material.transparency_method = 'Z_TRANSPARENCY'
979 material.use_shadeless = (shader == 'SHADELESS')
980 material.use_transparent_shadows = (shader == 'DIFFUSE')
981 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
983 # -------------------------------------------------------------------------
984 # Cycles/Eevee
985 def create_cycles_texnode(self, context, node_tree, img_spec):
986 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
987 tex_image.image = img_spec.image
988 tex_image.show_texture = True
989 self.apply_texture_options(tex_image, img_spec)
990 return tex_image
992 def create_cycles_material(self, context, img_spec):
993 image = img_spec.image
994 name_compat = bpy.path.display_name_from_filepath(image.filepath)
995 material = None
996 if self.overwrite_material:
997 for mat in bpy.data.materials:
998 if mat.name == name_compat:
999 material = mat
1000 if not material:
1001 material = bpy.data.materials.new(name=name_compat)
1003 material.use_nodes = True
1004 node_tree = material.node_tree
1005 out_node = clean_node_tree(node_tree)
1007 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1009 if self.shader == 'DIFFUSE':
1010 core_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
1011 elif self.shader == 'SHADELESS':
1012 core_shader = get_shadeless_node(node_tree)
1013 else: # Emission Shading
1014 core_shader = node_tree.nodes.new('ShaderNodeEmission')
1015 core_shader.inputs[1].default_value = self.emit_strength
1017 # Connect color from texture
1018 node_tree.links.new(core_shader.inputs[0], tex_image.outputs[0])
1020 if self.use_transparency:
1021 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1023 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1024 node_tree.links.new(mix_shader.inputs[0], tex_image.outputs[1])
1025 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs[0])
1026 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1027 core_shader = mix_shader
1029 node_tree.links.new(out_node.inputs[0], core_shader.outputs[0])
1031 auto_align_nodes(node_tree)
1032 return material
1034 # -------------------------------------------------------------------------
1035 # Geometry Creation
1036 def create_image_plane(self, context, name, img_spec):
1038 width, height = self.compute_plane_size(context, img_spec)
1040 # Create new mesh
1041 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1042 plane = context.active_object
1043 # Why does mesh.primitive_plane_add leave the object in edit mode???
1044 if plane.mode is not 'OBJECT':
1045 bpy.ops.object.mode_set(mode='OBJECT')
1046 plane.dimensions = width, height, 0.0
1047 plane.data.name = plane.name = name
1048 bpy.ops.object.transform_apply(scale=True)
1050 # If sizing for camera, also insert into the camera's field of view
1051 if self.size_mode == 'CAMERA':
1052 offset_axis = self.axis_id_to_vector[self.offset_axis]
1053 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1054 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1056 self.align_plane(context, plane)
1058 return plane
1060 def compute_plane_size(self, context, img_spec):
1061 """Given the image size in pixels and location, determine size of plane"""
1062 px, py = img_spec.size
1064 # can't load data
1065 if px == 0 or py == 0:
1066 px = py = 1
1068 if self.size_mode == 'ABSOLUTE':
1069 y = self.height
1070 x = px / py * y
1072 elif self.size_mode == 'CAMERA':
1073 x, y = compute_camera_size(
1074 context, context.scene.cursor_location,
1075 self.fill_mode, px / py
1078 elif self.size_mode == 'DPI':
1079 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1080 x = px * fact
1081 y = py * fact
1083 else: # elif self.size_mode == 'DPBU'
1084 fact = 1 / self.factor
1085 x = px * fact
1086 y = py * fact
1088 return x, y
1090 def align_plane(self, context, plane):
1091 """Pick an axis and align the plane to it"""
1092 if 'CAM' in self.align_axis:
1093 # Camera-aligned
1094 camera = context.scene.camera
1095 if (camera):
1096 # Find the axis that best corresponds to the camera's view direction
1097 axis = camera.matrix_world @ \
1098 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1099 # pick the axis with the greatest magnitude
1100 mag = max(map(abs, axis))
1101 # And use that axis & direction
1102 axis = Vector([
1103 n / mag if abs(n) == mag else 0.0
1104 for n in axis
1106 else:
1107 # No camera? Just face Z axis
1108 axis = Vector((0, 0, 1))
1109 self.align_axis = 'Z+'
1110 else:
1111 # Axis-aligned
1112 axis = self.axis_id_to_vector[self.align_axis]
1114 # rotate accodingly for x/y axiis
1115 if not axis.z:
1116 plane.rotation_euler.x = pi / 2
1118 if axis.y > 0:
1119 plane.rotation_euler.z = pi
1120 elif axis.y < 0:
1121 plane.rotation_euler.z = 0
1122 elif axis.x > 0:
1123 plane.rotation_euler.z = pi / 2
1124 elif axis.x < 0:
1125 plane.rotation_euler.z = -pi / 2
1127 # or flip 180 degrees for negative z
1128 elif axis.z < 0:
1129 plane.rotation_euler.y = pi
1131 if self.align_axis == 'CAM':
1132 constraint = plane.constraints.new('COPY_ROTATION')
1133 constraint.target = camera
1134 constraint.use_x = constraint.use_y = constraint.use_z = True
1135 if not self.align_track:
1136 bpy.ops.object.visual_transform_apply()
1137 plane.constraints.clear()
1139 if self.align_axis == 'CAM_AX' and self.align_track:
1140 constraint = plane.constraints.new('LOCKED_TRACK')
1141 constraint.target = camera
1142 constraint.track_axis = 'TRACK_Z'
1143 constraint.lock_axis = 'LOCK_Y'
1146 # -----------------------------------------------------------------------------
1147 # Register
1149 def import_images_button(self, context):
1150 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1153 classes = (
1154 IMPORT_IMAGE_OT_to_plane,
1158 def register():
1159 for cls in classes:
1160 bpy.utils.register_class(cls)
1162 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1163 bpy.types.VIEW3D_MT_mesh_add.append(import_images_button)
1165 bpy.app.handlers.load_post.append(register_driver)
1166 register_driver()
1169 def unregister():
1170 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1171 bpy.types.VIEW3D_MT_mesh_add.remove(import_images_button)
1173 # This will only exist if drivers are active
1174 if check_drivers in bpy.app.handlers.scene_update_post:
1175 bpy.app.handlers.scene_update_post.remove(check_drivers)
1177 bpy.app.handlers.load_post.remove(register_driver)
1178 del bpy.app.driver_namespace['import_image__find_plane_corner']
1180 for cls in classes:
1181 bpy.utils.unregister_class(cls)
1184 if __name__ == "__main__":
1185 # Run simple doc tests
1186 import doctest
1187 doctest.testmod()
1189 unregister()
1190 register()