animation_animall: return to release: T68332 T63750 e6a1dfbe53be
[blender-addons.git] / io_import_images_as_planes.py
blobca55ef700f6bffdb445d21438b7f6abb5eef1ebd
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, 2),
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 "support": 'OFFICIAL',
33 "category": "Import-Export",
36 import os
37 import warnings
38 import re
39 from itertools import count, repeat
40 from collections import namedtuple
41 from math import pi
43 import bpy
44 from bpy.types import Operator
45 from mathutils import Vector
47 from bpy.props import (
48 StringProperty,
49 BoolProperty,
50 EnumProperty,
51 FloatProperty,
52 CollectionProperty,
55 from bpy_extras.object_utils import (
56 AddObjectHelper,
57 world_to_camera_view,
60 from bpy_extras.image_utils import load_image
62 # -----------------------------------------------------------------------------
63 # Module-level Shared State
65 watched_objects = {} # used to trigger compositor updates on scene updates
68 # -----------------------------------------------------------------------------
69 # Misc utils.
71 def add_driver_prop(driver, name, type, id, path):
72 """Configure a new driver variable."""
73 dv = driver.variables.new()
74 dv.name = name
75 dv.type = 'SINGLE_PROP'
76 target = dv.targets[0]
77 target.id_type = type
78 target.id = id
79 target.data_path = path
82 # -----------------------------------------------------------------------------
83 # Image loading
85 ImageSpec = namedtuple(
86 'ImageSpec',
87 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
89 num_regex = re.compile('[0-9]') # Find a single number
90 nums_regex = re.compile('[0-9]+') # Find a set of numbers
93 def find_image_sequences(files):
94 """From a group of files, detect image sequences.
96 This returns a generator of tuples, which contain the filename,
97 start frame, and length of the detected sequence
99 >>> list(find_image_sequences([
100 ... "test2-001.jp2", "test2-002.jp2",
101 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
102 ... "blaah"]))
103 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
106 files = iter(sorted(files))
107 prev_file = None
108 pattern = ""
109 matches = []
110 segment = None
111 length = 1
112 for filename in files:
113 new_pattern = num_regex.sub('#', filename)
114 new_matches = list(map(int, nums_regex.findall(filename)))
115 if new_pattern == pattern:
116 # this file looks like it may be in sequence from the previous
118 # if there are multiple sets of numbers, figure out what changed
119 if segment is None:
120 for i, prev, cur in zip(count(), matches, new_matches):
121 if prev != cur:
122 segment = i
123 break
125 # did it only change by one?
126 for i, prev, cur in zip(count(), matches, new_matches):
127 if i == segment:
128 # We expect this to increment
129 prev = prev + length
130 if prev != cur:
131 break
133 # All good!
134 else:
135 length += 1
136 continue
138 # No continuation -> spit out what we found and reset counters
139 if prev_file:
140 if length > 1:
141 yield prev_file, matches[segment], length
142 else:
143 yield prev_file, 1, 1
145 prev_file = filename
146 matches = new_matches
147 pattern = new_pattern
148 segment = None
149 length = 1
151 if prev_file:
152 if length > 1:
153 yield prev_file, matches[segment], length
154 else:
155 yield prev_file, 1, 1
158 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
159 """Wrapper for bpy's load_image
161 Loads a set of images, movies, or even image sequences
162 Returns a generator of ImageSpec wrapper objects later used for texture setup
164 if find_sequences: # if finding sequences, we need some pre-processing first
165 file_iter = find_image_sequences(filenames)
166 else:
167 file_iter = zip(filenames, repeat(1), repeat(1))
169 for filename, offset, frames in file_iter:
170 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
172 # Size is unavailable for sequences, so we grab it early
173 size = tuple(image.size)
175 if image.source == 'MOVIE':
176 # Blender BPY BUG!
177 # This number is only valid when read a second time in 2.77
178 # This repeated line is not a mistake
179 frames = image.frame_duration
180 frames = image.frame_duration
182 elif frames > 1: # Not movie, but multiple frames -> image sequence
183 image.source = 'SEQUENCE'
185 yield ImageSpec(image, size, frame_start, offset - 1, frames)
188 # -----------------------------------------------------------------------------
189 # Position & Size Helpers
191 def offset_planes(planes, gap, axis):
192 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
194 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
195 obj2 0.5 blender units away from obj1 along the local positive Z axis.
197 This is in local space, not world space, so all planes should share
198 a common scale and rotation.
200 prior = planes[0]
201 offset = Vector()
202 for current in planes[1:]:
203 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
205 offset += local_offset * axis
206 current.location = current.matrix_world @ offset
208 prior = current
211 def compute_camera_size(context, center, fill_mode, aspect):
212 """Determine how large an object needs to be to fit or fill the camera's field of view."""
213 scene = context.scene
214 camera = scene.camera
215 view_frame = camera.data.view_frame(scene=scene)
216 frame_size = \
217 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
218 Vector([min(v[i] for v in view_frame) for i in range(3)])
219 camera_aspect = frame_size.x / frame_size.y
221 # Convert the frame size to the correct sizing at a given distance
222 if camera.type == 'ORTHO':
223 frame_size = frame_size.xy
224 else:
225 # Perspective transform
226 distance = world_to_camera_view(scene, camera, center).z
227 frame_size = distance * frame_size.xy / (-view_frame[0].z)
229 # Determine what axis to match to the camera
230 match_axis = 0 # match the Y axis size
231 match_aspect = aspect
232 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
233 (fill_mode == 'FIT' and aspect < camera_aspect):
234 match_axis = 1 # match the X axis size
235 match_aspect = 1.0 / aspect
237 # scale the other axis to the correct aspect
238 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
240 return frame_size
243 def center_in_camera(scene, camera, obj, axis=(1, 1)):
244 """Center object along specified axis of the camera"""
245 camera_matrix_col = camera.matrix_world.col
246 location = obj.location
248 # Vector from the camera's world coordinate center to the object's center
249 delta = camera_matrix_col[3].xyz - location
251 # How far off center we are along the camera's local X
252 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
253 # How far off center we are along the camera's local Y
254 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
256 # Now offset only along camera local axis
257 offset = camera_matrix_col[0].xyz * camera_x_mag + \
258 camera_matrix_col[1].xyz * camera_y_mag
260 obj.location = location + offset
263 # -----------------------------------------------------------------------------
264 # Cycles/Eevee utils
266 def get_input_nodes(node, links):
267 """Get nodes that are a inputs to the given node"""
268 # Get all links going to node.
269 input_links = {lnk for lnk in links if lnk.to_node == node}
270 # Sort those links, get their input nodes (and avoid doubles!).
271 sorted_nodes = []
272 done_nodes = set()
273 for socket in node.inputs:
274 done_links = set()
275 for link in input_links:
276 nd = link.from_node
277 if nd in done_nodes:
278 # Node already treated!
279 done_links.add(link)
280 elif link.to_socket == socket:
281 sorted_nodes.append(nd)
282 done_links.add(link)
283 done_nodes.add(nd)
284 input_links -= done_links
285 return sorted_nodes
288 def auto_align_nodes(node_tree):
289 """Given a shader node tree, arrange nodes neatly relative to the output node."""
290 x_gap = 200
291 y_gap = 180
292 nodes = node_tree.nodes
293 links = node_tree.links
294 output_node = None
295 for node in nodes:
296 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
297 output_node = node
298 break
300 else: # Just in case there is no output
301 return
303 def align(to_node):
304 from_nodes = get_input_nodes(to_node, links)
305 for i, node in enumerate(from_nodes):
306 node.location.x = min(node.location.x, to_node.location.x - x_gap)
307 node.location.y = to_node.location.y
308 node.location.y -= i * y_gap
309 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
310 align(node)
312 align(output_node)
315 def clean_node_tree(node_tree):
316 """Clear all nodes in a shader node tree except the output.
318 Returns the output node
320 nodes = node_tree.nodes
321 for node in list(nodes): # copy to avoid altering the loop's data source
322 if not node.type == 'OUTPUT_MATERIAL':
323 nodes.remove(node)
325 return node_tree.nodes[0]
328 def get_shadeless_node(dest_node_tree):
329 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
330 try:
331 node_tree = bpy.data.node_groups['IAP_SHADELESS']
333 except KeyError:
334 # need to build node shadeless node group
335 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
336 output_node = node_tree.nodes.new('NodeGroupOutput')
337 input_node = node_tree.nodes.new('NodeGroupInput')
339 node_tree.outputs.new('NodeSocketShader', 'Shader')
340 node_tree.inputs.new('NodeSocketColor', 'Color')
342 # This could be faster as a transparent shader, but then no ambient occlusion
343 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
344 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
346 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
347 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
349 light_path = node_tree.nodes.new('ShaderNodeLightPath')
350 is_glossy_ray = light_path.outputs['Is Glossy Ray']
351 is_shadow_ray = light_path.outputs['Is Shadow Ray']
352 ray_depth = light_path.outputs['Ray Depth']
353 transmission_depth = light_path.outputs['Transmission Depth']
355 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
356 unrefracted_depth.operation = 'SUBTRACT'
357 unrefracted_depth.label = 'Bounce Count'
358 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
359 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
361 refracted = node_tree.nodes.new('ShaderNodeMath')
362 refracted.operation = 'SUBTRACT'
363 refracted.label = 'Camera or Refracted'
364 refracted.inputs[0].default_value = 1.0
365 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
367 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
368 reflection_limit.operation = 'SUBTRACT'
369 reflection_limit.label = 'Limit Reflections'
370 reflection_limit.inputs[0].default_value = 2.0
371 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
373 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
374 camera_reflected.operation = 'MULTIPLY'
375 camera_reflected.label = 'Camera Ray to Glossy'
376 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
377 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
379 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
380 shadow_or_reflect.operation = 'MAXIMUM'
381 shadow_or_reflect.label = 'Shadow or Reflection?'
382 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
383 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
385 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
386 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
387 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
388 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
389 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
391 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
392 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
393 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
394 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
396 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
398 auto_align_nodes(node_tree)
400 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
401 group_node.node_tree = node_tree
403 return group_node
406 # -----------------------------------------------------------------------------
407 # Corner Pin Driver Helpers
409 @bpy.app.handlers.persistent
410 def check_drivers(*args, **kwargs):
411 """Check if watched objects in a scene have changed and trigger compositor update
413 This is part of a hack to ensure the compositor updates
414 itself when the objects used for drivers change.
416 It only triggers if transformation matricies change to avoid
417 a cyclic loop of updates.
419 if not watched_objects:
420 # if there is nothing to watch, don't bother running this
421 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
422 return
424 update = False
425 for name, matrix in list(watched_objects.items()):
426 try:
427 obj = bpy.data.objects[name]
428 except KeyError:
429 # The user must have removed this object
430 del watched_objects[name]
431 else:
432 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
433 if new_matrix != matrix:
434 watched_objects[name] = new_matrix
435 update = True
437 if update:
438 # Trick to re-evaluate drivers
439 bpy.context.scene.frame_current = bpy.context.scene.frame_current
442 def register_watched_object(obj):
443 """Register an object to be monitored for transformation changes"""
444 name = obj.name
446 # known object? -> we're done
447 if name in watched_objects:
448 return
450 if not watched_objects:
451 # make sure check_drivers is active
452 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
454 watched_objects[name] = None
457 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
458 """Find the location in camera space of a plane's corner"""
459 if args or kwargs:
460 # I've added args / kwargs as a compatibility measure with future versions
461 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
463 plane = bpy.data.objects[object_name]
465 # Passing in camera doesn't work before 2.78, so we use the current one
466 camera = camera or bpy.context.scene.camera
468 # Hack to ensure compositor updates on future changes
469 register_watched_object(camera)
470 register_watched_object(plane)
472 scale = plane.scale * 2.0
473 v = plane.dimensions.copy()
474 v.x *= x / scale.x
475 v.y *= y / scale.y
476 v = plane.matrix_world @ v
478 camera_vertex = world_to_camera_view(
479 bpy.context.scene, camera, v)
481 return camera_vertex[axis]
484 @bpy.app.handlers.persistent
485 def register_driver(*args, **kwargs):
486 """Register the find_plane_corner function for use with drivers"""
487 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
490 # -----------------------------------------------------------------------------
491 # Compositing Helpers
493 def group_in_frame(node_tree, name, nodes):
494 frame_node = node_tree.nodes.new("NodeFrame")
495 frame_node.label = name
496 frame_node.name = name + "_frame"
498 min_pos = Vector(nodes[0].location)
499 max_pos = min_pos.copy()
501 for node in nodes:
502 top_left = node.location
503 bottom_right = top_left + Vector((node.width, -node.height))
505 for i in (0, 1):
506 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
507 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
509 node.parent = frame_node
511 frame_node.width = max_pos[0] - min_pos[0] + 50
512 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
513 frame_node.shrink = True
515 return frame_node
518 def position_frame_bottom_left(node_tree, frame_node):
519 newpos = Vector((100000, 100000)) # start reasonably far top / right
521 # Align with the furthest left
522 for node in node_tree.nodes.values():
523 if node != frame_node and node.parent != frame_node:
524 newpos.x = min(newpos.x, node.location.x + 30)
526 # As high as we can get without overlapping anything to the right
527 for node in node_tree.nodes.values():
528 if node != frame_node and not node.parent:
529 if node.location.x < newpos.x + frame_node.width:
530 print("Below", node.name, node.location, node.height, node.dimensions)
531 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
533 frame_node.location = newpos
536 def setup_compositing(context, plane, img_spec):
537 # Node Groups only work with "new" dependency graph and even
538 # then it has some problems with not updating the first time
539 # So instead this groups with a node frame, which works reliably
541 scene = context.scene
542 scene.use_nodes = True
543 node_tree = scene.node_tree
544 name = plane.name
546 image_node = node_tree.nodes.new("CompositorNodeImage")
547 image_node.name = name + "_image"
548 image_node.image = img_spec.image
549 image_node.location = Vector((0, 0))
550 image_node.frame_start = img_spec.frame_start
551 image_node.frame_offset = img_spec.frame_offset
552 image_node.frame_duration = img_spec.frame_duration
554 scale_node = node_tree.nodes.new("CompositorNodeScale")
555 scale_node.name = name + "_scale"
556 scale_node.space = 'RENDER_SIZE'
557 scale_node.location = image_node.location + \
558 Vector((image_node.width + 20, 0))
559 scale_node.show_options = False
561 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
562 cornerpin_node.name = name + "_cornerpin"
563 cornerpin_node.location = scale_node.location + \
564 Vector((0, -scale_node.height))
566 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
567 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
569 # Put all the nodes in a frame for organization
570 frame_node = group_in_frame(
571 node_tree, name,
572 (image_node, scale_node, cornerpin_node)
575 # Position frame at bottom / left
576 position_frame_bottom_left(node_tree, frame_node)
578 # Configure Drivers
579 for corner in cornerpin_node.inputs[1:]:
580 id = corner.identifier
581 x = -1 if 'Left' in id else 1
582 y = -1 if 'Lower' in id else 1
583 drivers = corner.driver_add('default_value')
584 for i, axis_fcurve in enumerate(drivers):
585 driver = axis_fcurve.driver
586 # Always use the current camera
587 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
588 # Track camera location to ensure Deps Graph triggers (not used in the call)
589 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
590 # Don't break if the name changes
591 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
592 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
593 repr(plane.name),
594 x, y, i
596 driver.type = 'SCRIPTED'
597 driver.is_valid = True
598 axis_fcurve.is_valid = True
599 driver.expression = "%s" % driver.expression
601 context.view_layer.update()
604 # -----------------------------------------------------------------------------
605 # Operator
607 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
608 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
610 bl_idname = "import_image.to_plane"
611 bl_label = "Import Images as Planes"
612 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
614 # ----------------------
615 # File dialog properties
616 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
618 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
620 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
621 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
622 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
624 # ----------------------
625 # Properties - Importing
626 force_reload: BoolProperty(
627 name="Force Reload", default=False,
628 description="Force reloading of the image if already opened elsewhere in Blender"
631 image_sequence: BoolProperty(
632 name="Animate Image Sequences", default=False,
633 description="Import sequentially numbered images as an animated "
634 "image sequence instead of separate planes"
637 # -------------------------------------
638 # Properties - Position and Orientation
639 axis_id_to_vector = {
640 'X+': Vector(( 1, 0, 0)),
641 'Y+': Vector(( 0, 1, 0)),
642 'Z+': Vector(( 0, 0, 1)),
643 'X-': Vector((-1, 0, 0)),
644 'Y-': Vector(( 0, -1, 0)),
645 'Z-': Vector(( 0, 0, -1)),
648 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
650 OFFSET_MODES = (
651 ('X+', "X+", "Side by Side to the Left"),
652 ('Y+', "Y+", "Side by Side, Downward"),
653 ('Z+', "Z+", "Stacked Above"),
654 ('X-', "X-", "Side by Side to the Right"),
655 ('Y-', "Y-", "Side by Side, Upward"),
656 ('Z-', "Z-", "Stacked Below"),
658 offset_axis: EnumProperty(
659 name="Orientation", default='X+', items=OFFSET_MODES,
660 description="How planes are oriented relative to each others' local axis"
663 offset_amount: FloatProperty(
664 name="Offset", soft_min=0, default=0.1, description="Space between planes",
665 subtype='DISTANCE', unit='LENGTH'
668 AXIS_MODES = (
669 ('X+', "X+", "Facing Positive X"),
670 ('Y+', "Y+", "Facing Positive Y"),
671 ('Z+', "Z+ (Up)", "Facing Positive Z"),
672 ('X-', "X-", "Facing Negative X"),
673 ('Y-', "Y-", "Facing Negative Y"),
674 ('Z-', "Z- (Down)", "Facing Negative Z"),
675 ('CAM', "Face Camera", "Facing Camera"),
676 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
678 align_axis: EnumProperty(
679 name="Align", default='CAM_AX', items=AXIS_MODES,
680 description="How to align the planes"
682 # prev_align_axis is used only by update_size_model
683 prev_align_axis: EnumProperty(
684 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
685 align_track: BoolProperty(
686 name="Track Camera", default=False, description="Always face the camera"
689 # -----------------
690 # Properties - Size
691 def update_size_mode(self, context):
692 """If sizing relative to the camera, always face the camera"""
693 if self.size_mode == 'CAMERA':
694 self.prev_align_axis = self.align_axis
695 self.align_axis = 'CAM'
696 else:
697 # if a different alignment was set revert to that when
698 # size mode is changed
699 if self.prev_align_axis != 'NONE':
700 self.align_axis = self.prev_align_axis
701 self._prev_align_axis = 'NONE'
703 SIZE_MODES = (
704 ('ABSOLUTE', "Absolute", "Use absolute size"),
705 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
706 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
707 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
709 size_mode: EnumProperty(
710 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
711 update=update_size_mode,
712 description="How the size of the plane is computed")
714 FILL_MODES = (
715 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
716 ('FIT', "Fit", "Fit entire image within the camera frame"),
718 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
719 description="How large in the camera frame is the plane")
721 height: FloatProperty(name="Height", description="Height of the created plane",
722 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
724 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
725 description="Number of pixels per inch or Blender Unit")
727 # ------------------------------
728 # Properties - Material / Shader
729 SHADERS = (
730 ('DIFFUSE', "Diffuse", "Diffuse Shader"),
731 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
732 ('EMISSION', "Emit", "Emission Shader"),
734 shader: EnumProperty(name="Shader", items=SHADERS, default='DIFFUSE', description="Node shader to use")
736 emit_strength: FloatProperty(
737 name="Strength", min=0.0, default=1.0, soft_max=10.0,
738 step=100, description="Brightness of Emission Texture")
740 overwrite_material: BoolProperty(
741 name="Overwrite Material", default=True,
742 description="Overwrite existing Material (based on material name)")
744 compositing_nodes: BoolProperty(
745 name="Setup Corner Pin", default=False,
746 description="Build Compositor Nodes to reference this image "
747 "without re-rendering")
749 # ------------------
750 # Properties - Image
751 use_transparency: BoolProperty(
752 name="Use Alpha", default=True,
753 description="Use alpha channel for transparency")
755 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
756 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
757 alpha_mode: EnumProperty(
758 name=t.name, items=alpha_mode_items, default=t.default,
759 description=t.description)
761 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
762 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
764 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
766 # -------
767 # Draw UI
768 def draw_import_config(self, context):
769 # --- Import Options --- #
770 layout = self.layout
771 box = layout.box()
773 box.label(text="Import Options:", icon='IMPORT')
774 row = box.row()
775 row.active = bpy.data.is_saved
776 row.prop(self, "relative")
778 box.prop(self, "force_reload")
779 box.prop(self, "image_sequence")
781 def draw_material_config(self, context):
782 # --- Material / Rendering Properties --- #
783 layout = self.layout
784 box = layout.box()
786 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
787 box.prop(self, "compositing_nodes")
789 box.label(text="Material Settings:", icon='MATERIAL')
791 row = box.row()
792 row.prop(self, 'shader', expand=True)
793 if self.shader == 'EMISSION':
794 box.prop(self, "emit_strength")
796 engine = context.scene.render.engine
797 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
798 box.label(text="%s is not supported" % engine, icon='ERROR')
800 box.prop(self, "overwrite_material")
802 box.label(text="Texture Settings:", icon='TEXTURE')
803 row = box.row()
804 row.prop(self, "use_transparency")
805 sub = row.row()
806 sub.active = self.use_transparency
807 sub.prop(self, "alpha_mode", text="")
808 box.prop(self, "use_auto_refresh")
810 def draw_spatial_config(self, context):
811 # --- Spatial Properties: Position, Size and Orientation --- #
812 layout = self.layout
813 box = layout.box()
815 box.label(text="Position:", icon='SNAP_GRID')
816 box.prop(self, "offset")
817 col = box.column()
818 row = col.row()
819 row.prop(self, "offset_axis", expand=True)
820 row = col.row()
821 row.prop(self, "offset_amount")
822 col.enabled = self.offset
824 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
825 row = box.row()
826 row.prop(self, "size_mode", expand=True)
827 if self.size_mode == 'ABSOLUTE':
828 box.prop(self, "height")
829 elif self.size_mode == 'CAMERA':
830 row = box.row()
831 row.prop(self, "fill_mode", expand=True)
832 else:
833 box.prop(self, "factor")
835 box.label(text="Orientation:")
836 row = box.row()
837 row.enabled = 'CAM' not in self.size_mode
838 row.prop(self, "align_axis")
839 row = box.row()
840 row.enabled = 'CAM' in self.align_axis
841 row.alignment = 'RIGHT'
842 row.prop(self, "align_track")
844 def draw(self, context):
846 # Draw configuration sections
847 self.draw_import_config(context)
848 self.draw_material_config(context)
849 self.draw_spatial_config(context)
851 # -------------------------------------------------------------------------
852 # Core functionality
853 def invoke(self, context, event):
854 engine = context.scene.render.engine
855 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
856 if engine != 'BLENDER_WORKBENCH':
857 self.report({'ERROR'}, "Cannot generate materials for unknown %s render engine" % engine)
858 return {'CANCELLED'}
859 else:
860 self.report({'WARNING'},
861 "Generating Cycles/EEVEE compatible material, but won't be visible with %s engine" % engine)
863 # Open file browser
864 context.window_manager.fileselect_add(self)
865 return {'RUNNING_MODAL'}
867 def execute(self, context):
868 if not bpy.data.is_saved:
869 self.relative = False
871 # this won't work in edit mode
872 editmode = context.preferences.edit.use_enter_edit_mode
873 context.preferences.edit.use_enter_edit_mode = False
874 if context.active_object and context.active_object.mode != 'OBJECT':
875 bpy.ops.object.mode_set(mode='OBJECT')
877 self.import_images(context)
879 context.preferences.edit.use_enter_edit_mode = editmode
881 return {'FINISHED'}
883 def import_images(self, context):
885 # load images / sequences
886 images = tuple(load_images(
887 (fn.name for fn in self.files),
888 self.directory,
889 force_reload=self.force_reload,
890 find_sequences=self.image_sequence
893 # Create individual planes
894 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
896 context.view_layer.update()
898 # Align planes relative to each other
899 if self.offset:
900 offset_axis = self.axis_id_to_vector[self.offset_axis]
901 offset_planes(planes, self.offset_amount, offset_axis)
903 if self.size_mode == 'CAMERA' and offset_axis.z:
904 for plane in planes:
905 x, y = compute_camera_size(
906 context, plane.location,
907 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
908 plane.dimensions = x, y, 0.0
910 # setup new selection
911 for plane in planes:
912 plane.select_set(True)
914 # all done!
915 self.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes)))
917 # operate on a single image
918 def single_image_spec_to_plane(self, context, img_spec):
920 # Configure image
921 self.apply_image_options(img_spec.image)
923 # Configure material
924 engine = context.scene.render.engine
925 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
926 material = self.create_cycles_material(context, img_spec)
928 # Create and position plane object
929 plane = self.create_image_plane(context, material.name, img_spec)
931 # Assign Material
932 plane.data.materials.append(material)
934 # If applicable, setup Corner Pin node
935 if self.compositing_nodes:
936 setup_compositing(context, plane, img_spec)
938 return plane
940 def apply_image_options(self, image):
941 if self.use_transparency == False:
942 image.alpha_mode = 'NONE'
943 else:
944 image.alpha_mode = self.alpha_mode
946 if self.relative:
947 try: # can't always find the relative path (between drive letters on windows)
948 image.filepath = bpy.path.relpath(image.filepath)
949 except ValueError:
950 pass
952 def apply_texture_options(self, texture, img_spec):
953 # Shared by both Cycles and Blender Internal
954 image_user = texture.image_user
955 image_user.use_auto_refresh = self.use_auto_refresh
956 image_user.frame_start = img_spec.frame_start
957 image_user.frame_offset = img_spec.frame_offset
958 image_user.frame_duration = img_spec.frame_duration
960 # Image sequences need auto refresh to display reliably
961 if img_spec.image.source == 'SEQUENCE':
962 image_user.use_auto_refresh = True
964 texture.extension = 'CLIP' # Default of "Repeat" can cause artifacts
966 def apply_material_options(self, material, slot):
967 shader = self.shader
969 if self.use_transparency:
970 material.alpha = 0.0
971 material.specular_alpha = 0.0
972 slot.use_map_alpha = True
973 else:
974 material.alpha = 1.0
975 material.specular_alpha = 1.0
976 slot.use_map_alpha = False
978 material.specular_intensity = 0
979 material.diffuse_intensity = 1.0
980 material.use_transparency = self.use_transparency
981 material.transparency_method = 'Z_TRANSPARENCY'
982 material.use_shadeless = (shader == 'SHADELESS')
983 material.use_transparent_shadows = (shader == 'DIFFUSE')
984 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
986 # -------------------------------------------------------------------------
987 # Cycles/Eevee
988 def create_cycles_texnode(self, context, node_tree, img_spec):
989 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
990 tex_image.image = img_spec.image
991 tex_image.show_texture = True
992 self.apply_texture_options(tex_image, img_spec)
993 return tex_image
995 def create_cycles_material(self, context, img_spec):
996 image = img_spec.image
997 name_compat = bpy.path.display_name_from_filepath(image.filepath)
998 material = None
999 if self.overwrite_material:
1000 for mat in bpy.data.materials:
1001 if mat.name == name_compat:
1002 material = mat
1003 if not material:
1004 material = bpy.data.materials.new(name=name_compat)
1006 material.use_nodes = True
1007 node_tree = material.node_tree
1008 out_node = clean_node_tree(node_tree)
1010 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1012 if self.shader == 'DIFFUSE':
1013 core_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
1014 elif self.shader == 'SHADELESS':
1015 core_shader = get_shadeless_node(node_tree)
1016 else: # Emission Shading
1017 core_shader = node_tree.nodes.new('ShaderNodeEmission')
1018 core_shader.inputs[1].default_value = self.emit_strength
1020 # Connect color from texture
1021 node_tree.links.new(core_shader.inputs[0], tex_image.outputs[0])
1023 if self.use_transparency:
1024 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1026 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1027 node_tree.links.new(mix_shader.inputs[0], tex_image.outputs[1])
1028 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs[0])
1029 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1030 core_shader = mix_shader
1032 node_tree.links.new(out_node.inputs[0], core_shader.outputs[0])
1034 auto_align_nodes(node_tree)
1035 return material
1037 # -------------------------------------------------------------------------
1038 # Geometry Creation
1039 def create_image_plane(self, context, name, img_spec):
1041 width, height = self.compute_plane_size(context, img_spec)
1043 # Create new mesh
1044 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1045 plane = context.active_object
1046 # Why does mesh.primitive_plane_add leave the object in edit mode???
1047 if plane.mode != 'OBJECT':
1048 bpy.ops.object.mode_set(mode='OBJECT')
1049 plane.dimensions = width, height, 0.0
1050 plane.data.name = plane.name = name
1051 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1053 # If sizing for camera, also insert into the camera's field of view
1054 if self.size_mode == 'CAMERA':
1055 offset_axis = self.axis_id_to_vector[self.offset_axis]
1056 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1057 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1059 self.align_plane(context, plane)
1061 return plane
1063 def compute_plane_size(self, context, img_spec):
1064 """Given the image size in pixels and location, determine size of plane"""
1065 px, py = img_spec.size
1067 # can't load data
1068 if px == 0 or py == 0:
1069 px = py = 1
1071 if self.size_mode == 'ABSOLUTE':
1072 y = self.height
1073 x = px / py * y
1075 elif self.size_mode == 'CAMERA':
1076 x, y = compute_camera_size(
1077 context, context.scene.cursor.location,
1078 self.fill_mode, px / py
1081 elif self.size_mode == 'DPI':
1082 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1083 x = px * fact
1084 y = py * fact
1086 else: # elif self.size_mode == 'DPBU'
1087 fact = 1 / self.factor
1088 x = px * fact
1089 y = py * fact
1091 return x, y
1093 def align_plane(self, context, plane):
1094 """Pick an axis and align the plane to it"""
1095 if 'CAM' in self.align_axis:
1096 # Camera-aligned
1097 camera = context.scene.camera
1098 if (camera):
1099 # Find the axis that best corresponds to the camera's view direction
1100 axis = camera.matrix_world @ \
1101 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1102 # pick the axis with the greatest magnitude
1103 mag = max(map(abs, axis))
1104 # And use that axis & direction
1105 axis = Vector([
1106 n / mag if abs(n) == mag else 0.0
1107 for n in axis
1109 else:
1110 # No camera? Just face Z axis
1111 axis = Vector((0, 0, 1))
1112 self.align_axis = 'Z+'
1113 else:
1114 # Axis-aligned
1115 axis = self.axis_id_to_vector[self.align_axis]
1117 # rotate accordingly for x/y axiis
1118 if not axis.z:
1119 plane.rotation_euler.x = pi / 2
1121 if axis.y > 0:
1122 plane.rotation_euler.z = pi
1123 elif axis.y < 0:
1124 plane.rotation_euler.z = 0
1125 elif axis.x > 0:
1126 plane.rotation_euler.z = pi / 2
1127 elif axis.x < 0:
1128 plane.rotation_euler.z = -pi / 2
1130 # or flip 180 degrees for negative z
1131 elif axis.z < 0:
1132 plane.rotation_euler.y = pi
1134 if self.align_axis == 'CAM':
1135 constraint = plane.constraints.new('COPY_ROTATION')
1136 constraint.target = camera
1137 constraint.use_x = constraint.use_y = constraint.use_z = True
1138 if not self.align_track:
1139 bpy.ops.object.visual_transform_apply()
1140 plane.constraints.clear()
1142 if self.align_axis == 'CAM_AX' and self.align_track:
1143 constraint = plane.constraints.new('LOCKED_TRACK')
1144 constraint.target = camera
1145 constraint.track_axis = 'TRACK_Z'
1146 constraint.lock_axis = 'LOCK_Y'
1149 # -----------------------------------------------------------------------------
1150 # Register
1152 def import_images_button(self, context):
1153 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1156 classes = (
1157 IMPORT_IMAGE_OT_to_plane,
1161 def register():
1162 for cls in classes:
1163 bpy.utils.register_class(cls)
1165 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1166 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1168 bpy.app.handlers.load_post.append(register_driver)
1169 register_driver()
1172 def unregister():
1173 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1174 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1176 # This will only exist if drivers are active
1177 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1178 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1180 bpy.app.handlers.load_post.remove(register_driver)
1181 del bpy.app.driver_namespace['import_image__find_plane_corner']
1183 for cls in classes:
1184 bpy.utils.unregister_class(cls)
1187 if __name__ == "__main__":
1188 # Run simple doc tests
1189 import doctest
1190 doctest.testmod()
1192 unregister()
1193 register()