camera_dolly_crane_rigs: update for 2.8
[blender-addons.git] / io_import_images_as_planes.py
blob2b57c6763af870e4dac188811a8b7b7e66452636
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, 1),
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.scene_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.scene_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 scene.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 alphachannel 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_OPENGL'):
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 not in {'BLENDER_OPENGL'}:
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.user_preferences.edit.use_enter_edit_mode
873 context.user_preferences.edit.use_enter_edit_mode = False
874 if context.active_object and context.active_object.mode == 'EDIT':
875 bpy.ops.object.mode_set(mode='OBJECT')
877 self.import_images(context)
879 context.user_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.scene.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_OPENGL'}:
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 image.use_alpha = self.use_transparency
942 image.alpha_mode = self.alpha_mode
944 if self.relative:
945 try: # can't always find the relative path (between drive letters on windows)
946 image.filepath = bpy.path.relpath(image.filepath)
947 except ValueError:
948 pass
950 def apply_texture_options(self, texture, img_spec):
951 # Shared by both Cycles and Blender Internal
952 image_user = texture.image_user
953 image_user.use_auto_refresh = self.use_auto_refresh
954 image_user.frame_start = img_spec.frame_start
955 image_user.frame_offset = img_spec.frame_offset
956 image_user.frame_duration = img_spec.frame_duration
958 # Image sequences need auto refresh to display reliably
959 if img_spec.image.source == 'SEQUENCE':
960 image_user.use_auto_refresh = True
962 texture.extension = 'CLIP' # Default of "Repeat" can cause artifacts
964 def apply_material_options(self, material, slot):
965 shader = self.shader
967 if self.use_transparency:
968 material.alpha = 0.0
969 material.specular_alpha = 0.0
970 slot.use_map_alpha = True
971 else:
972 material.alpha = 1.0
973 material.specular_alpha = 1.0
974 slot.use_map_alpha = False
976 material.specular_intensity = 0
977 material.diffuse_intensity = 1.0
978 material.use_transparency = self.use_transparency
979 material.transparency_method = 'Z_TRANSPARENCY'
980 material.use_shadeless = (shader == 'SHADELESS')
981 material.use_transparent_shadows = (shader == 'DIFFUSE')
982 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
984 # -------------------------------------------------------------------------
985 # Cycles/Eevee
986 def create_cycles_texnode(self, context, node_tree, img_spec):
987 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
988 tex_image.image = img_spec.image
989 tex_image.show_texture = True
990 self.apply_texture_options(tex_image, img_spec)
991 return tex_image
993 def create_cycles_material(self, context, img_spec):
994 image = img_spec.image
995 name_compat = bpy.path.display_name_from_filepath(image.filepath)
996 material = None
997 if self.overwrite_material:
998 for mat in bpy.data.materials:
999 if mat.name == name_compat:
1000 material = mat
1001 if not material:
1002 material = bpy.data.materials.new(name=name_compat)
1004 material.use_nodes = True
1005 node_tree = material.node_tree
1006 out_node = clean_node_tree(node_tree)
1008 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1010 if self.shader == 'DIFFUSE':
1011 core_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
1012 elif self.shader == 'SHADELESS':
1013 core_shader = get_shadeless_node(node_tree)
1014 else: # Emission Shading
1015 core_shader = node_tree.nodes.new('ShaderNodeEmission')
1016 core_shader.inputs[1].default_value = self.emit_strength
1018 # Connect color from texture
1019 node_tree.links.new(core_shader.inputs[0], tex_image.outputs[0])
1021 if self.use_transparency:
1022 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1024 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1025 node_tree.links.new(mix_shader.inputs[0], tex_image.outputs[1])
1026 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs[0])
1027 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1028 core_shader = mix_shader
1030 node_tree.links.new(out_node.inputs[0], core_shader.outputs[0])
1032 auto_align_nodes(node_tree)
1033 return material
1035 # -------------------------------------------------------------------------
1036 # Geometry Creation
1037 def create_image_plane(self, context, name, img_spec):
1039 width, height = self.compute_plane_size(context, img_spec)
1041 # Create new mesh
1042 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1043 plane = context.active_object
1044 # Why does mesh.primitive_plane_add leave the object in edit mode???
1045 if plane.mode is not 'OBJECT':
1046 bpy.ops.object.mode_set(mode='OBJECT')
1047 plane.dimensions = width, height, 0.0
1048 plane.data.name = plane.name = name
1049 bpy.ops.object.transform_apply(scale=True)
1051 # If sizing for camera, also insert into the camera's field of view
1052 if self.size_mode == 'CAMERA':
1053 offset_axis = self.axis_id_to_vector[self.offset_axis]
1054 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1055 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1057 self.align_plane(context, plane)
1059 return plane
1061 def compute_plane_size(self, context, img_spec):
1062 """Given the image size in pixels and location, determine size of plane"""
1063 px, py = img_spec.size
1065 # can't load data
1066 if px == 0 or py == 0:
1067 px = py = 1
1069 if self.size_mode == 'ABSOLUTE':
1070 y = self.height
1071 x = px / py * y
1073 elif self.size_mode == 'CAMERA':
1074 x, y = compute_camera_size(
1075 context, context.scene.cursor_location,
1076 self.fill_mode, px / py
1079 elif self.size_mode == 'DPI':
1080 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1081 x = px * fact
1082 y = py * fact
1084 else: # elif self.size_mode == 'DPBU'
1085 fact = 1 / self.factor
1086 x = px * fact
1087 y = py * fact
1089 return x, y
1091 def align_plane(self, context, plane):
1092 """Pick an axis and align the plane to it"""
1093 if 'CAM' in self.align_axis:
1094 # Camera-aligned
1095 camera = context.scene.camera
1096 if (camera):
1097 # Find the axis that best corresponds to the camera's view direction
1098 axis = camera.matrix_world @ \
1099 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1100 # pick the axis with the greatest magnitude
1101 mag = max(map(abs, axis))
1102 # And use that axis & direction
1103 axis = Vector([
1104 n / mag if abs(n) == mag else 0.0
1105 for n in axis
1107 else:
1108 # No camera? Just face Z axis
1109 axis = Vector((0, 0, 1))
1110 self.align_axis = 'Z+'
1111 else:
1112 # Axis-aligned
1113 axis = self.axis_id_to_vector[self.align_axis]
1115 # rotate accordingly for x/y axiis
1116 if not axis.z:
1117 plane.rotation_euler.x = pi / 2
1119 if axis.y > 0:
1120 plane.rotation_euler.z = pi
1121 elif axis.y < 0:
1122 plane.rotation_euler.z = 0
1123 elif axis.x > 0:
1124 plane.rotation_euler.z = pi / 2
1125 elif axis.x < 0:
1126 plane.rotation_euler.z = -pi / 2
1128 # or flip 180 degrees for negative z
1129 elif axis.z < 0:
1130 plane.rotation_euler.y = pi
1132 if self.align_axis == 'CAM':
1133 constraint = plane.constraints.new('COPY_ROTATION')
1134 constraint.target = camera
1135 constraint.use_x = constraint.use_y = constraint.use_z = True
1136 if not self.align_track:
1137 bpy.ops.object.visual_transform_apply()
1138 plane.constraints.clear()
1140 if self.align_axis == 'CAM_AX' and self.align_track:
1141 constraint = plane.constraints.new('LOCKED_TRACK')
1142 constraint.target = camera
1143 constraint.track_axis = 'TRACK_Z'
1144 constraint.lock_axis = 'LOCK_Y'
1147 # -----------------------------------------------------------------------------
1148 # Register
1150 def import_images_button(self, context):
1151 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1154 classes = (
1155 IMPORT_IMAGE_OT_to_plane,
1159 def register():
1160 for cls in classes:
1161 bpy.utils.register_class(cls)
1163 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1164 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1166 bpy.app.handlers.load_post.append(register_driver)
1167 register_driver()
1170 def unregister():
1171 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1172 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1174 # This will only exist if drivers are active
1175 if check_drivers in bpy.app.handlers.scene_update_post:
1176 bpy.app.handlers.scene_update_post.remove(check_drivers)
1178 bpy.app.handlers.load_post.remove(register_driver)
1179 del bpy.app.driver_namespace['import_image__find_plane_corner']
1181 for cls in classes:
1182 bpy.utils.unregister_class(cls)
1185 if __name__ == "__main__":
1186 # Run simple doc tests
1187 import doctest
1188 doctest.testmod()
1190 unregister()
1191 register()