File headers: use SPDX license identifiers
[blender-addons.git] / io_import_images_as_planes.py
blob732044e99347cb3c5408e53489153f8cbef5101d
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 # <pep8 compliant>
5 bl_info = {
6 "name": "Import Images as Planes",
7 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc)",
8 "version": (3, 4, 0),
9 "blender": (2, 91, 0),
10 "location": "File > Import > Images as Planes or Add > Mesh > Images as Planes",
11 "description": "Imports images and creates planes with the appropriate aspect ratio. "
12 "The images are mapped to the planes.",
13 "warning": "",
14 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/images_as_planes.html",
15 "support": 'OFFICIAL',
16 "category": "Import-Export",
19 import os
20 import warnings
21 import re
22 from itertools import count, repeat
23 from collections import namedtuple
24 from math import pi
26 import bpy
27 from bpy.types import Operator
28 from mathutils import Vector
30 from bpy.props import (
31 StringProperty,
32 BoolProperty,
33 EnumProperty,
34 FloatProperty,
35 CollectionProperty,
38 from bpy_extras.object_utils import (
39 AddObjectHelper,
40 world_to_camera_view,
43 from bpy_extras.image_utils import load_image
45 # -----------------------------------------------------------------------------
46 # Module-level Shared State
48 watched_objects = {} # used to trigger compositor updates on scene updates
51 # -----------------------------------------------------------------------------
52 # Misc utils.
54 def add_driver_prop(driver, name, type, id, path):
55 """Configure a new driver variable."""
56 dv = driver.variables.new()
57 dv.name = name
58 dv.type = 'SINGLE_PROP'
59 target = dv.targets[0]
60 target.id_type = type
61 target.id = id
62 target.data_path = path
65 # -----------------------------------------------------------------------------
66 # Image loading
68 ImageSpec = namedtuple(
69 'ImageSpec',
70 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
72 num_regex = re.compile('[0-9]') # Find a single number
73 nums_regex = re.compile('[0-9]+') # Find a set of numbers
76 def find_image_sequences(files):
77 """From a group of files, detect image sequences.
79 This returns a generator of tuples, which contain the filename,
80 start frame, and length of the detected sequence
82 >>> list(find_image_sequences([
83 ... "test2-001.jp2", "test2-002.jp2",
84 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
85 ... "blaah"]))
86 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
88 """
89 files = iter(sorted(files))
90 prev_file = None
91 pattern = ""
92 matches = []
93 segment = None
94 length = 1
95 for filename in files:
96 new_pattern = num_regex.sub('#', filename)
97 new_matches = list(map(int, nums_regex.findall(filename)))
98 if new_pattern == pattern:
99 # this file looks like it may be in sequence from the previous
101 # if there are multiple sets of numbers, figure out what changed
102 if segment is None:
103 for i, prev, cur in zip(count(), matches, new_matches):
104 if prev != cur:
105 segment = i
106 break
108 # did it only change by one?
109 for i, prev, cur in zip(count(), matches, new_matches):
110 if i == segment:
111 # We expect this to increment
112 prev = prev + length
113 if prev != cur:
114 break
116 # All good!
117 else:
118 length += 1
119 continue
121 # No continuation -> spit out what we found and reset counters
122 if prev_file:
123 if length > 1:
124 yield prev_file, matches[segment], length
125 else:
126 yield prev_file, 1, 1
128 prev_file = filename
129 matches = new_matches
130 pattern = new_pattern
131 segment = None
132 length = 1
134 if prev_file:
135 if length > 1:
136 yield prev_file, matches[segment], length
137 else:
138 yield prev_file, 1, 1
141 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
142 """Wrapper for bpy's load_image
144 Loads a set of images, movies, or even image sequences
145 Returns a generator of ImageSpec wrapper objects later used for texture setup
147 if find_sequences: # if finding sequences, we need some pre-processing first
148 file_iter = find_image_sequences(filenames)
149 else:
150 file_iter = zip(filenames, repeat(1), repeat(1))
152 for filename, offset, frames in file_iter:
153 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
155 # Size is unavailable for sequences, so we grab it early
156 size = tuple(image.size)
158 if image.source == 'MOVIE':
159 # Blender BPY BUG!
160 # This number is only valid when read a second time in 2.77
161 # This repeated line is not a mistake
162 frames = image.frame_duration
163 frames = image.frame_duration
165 elif frames > 1: # Not movie, but multiple frames -> image sequence
166 image.source = 'SEQUENCE'
168 yield ImageSpec(image, size, frame_start, offset - 1, frames)
171 # -----------------------------------------------------------------------------
172 # Position & Size Helpers
174 def offset_planes(planes, gap, axis):
175 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
177 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
178 obj2 0.5 blender units away from obj1 along the local positive Z axis.
180 This is in local space, not world space, so all planes should share
181 a common scale and rotation.
183 prior = planes[0]
184 offset = Vector()
185 for current in planes[1:]:
186 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
188 offset += local_offset * axis
189 current.location = current.matrix_world @ offset
191 prior = current
194 def compute_camera_size(context, center, fill_mode, aspect):
195 """Determine how large an object needs to be to fit or fill the camera's field of view."""
196 scene = context.scene
197 camera = scene.camera
198 view_frame = camera.data.view_frame(scene=scene)
199 frame_size = \
200 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
201 Vector([min(v[i] for v in view_frame) for i in range(3)])
202 camera_aspect = frame_size.x / frame_size.y
204 # Convert the frame size to the correct sizing at a given distance
205 if camera.type == 'ORTHO':
206 frame_size = frame_size.xy
207 else:
208 # Perspective transform
209 distance = world_to_camera_view(scene, camera, center).z
210 frame_size = distance * frame_size.xy / (-view_frame[0].z)
212 # Determine what axis to match to the camera
213 match_axis = 0 # match the Y axis size
214 match_aspect = aspect
215 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
216 (fill_mode == 'FIT' and aspect < camera_aspect):
217 match_axis = 1 # match the X axis size
218 match_aspect = 1.0 / aspect
220 # scale the other axis to the correct aspect
221 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
223 return frame_size
226 def center_in_camera(scene, camera, obj, axis=(1, 1)):
227 """Center object along specified axis of the camera"""
228 camera_matrix_col = camera.matrix_world.col
229 location = obj.location
231 # Vector from the camera's world coordinate center to the object's center
232 delta = camera_matrix_col[3].xyz - location
234 # How far off center we are along the camera's local X
235 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
236 # How far off center we are along the camera's local Y
237 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
239 # Now offset only along camera local axis
240 offset = camera_matrix_col[0].xyz * camera_x_mag + \
241 camera_matrix_col[1].xyz * camera_y_mag
243 obj.location = location + offset
246 # -----------------------------------------------------------------------------
247 # Cycles/Eevee utils
249 def get_input_nodes(node, links):
250 """Get nodes that are a inputs to the given node"""
251 # Get all links going to node.
252 input_links = {lnk for lnk in links if lnk.to_node == node}
253 # Sort those links, get their input nodes (and avoid doubles!).
254 sorted_nodes = []
255 done_nodes = set()
256 for socket in node.inputs:
257 done_links = set()
258 for link in input_links:
259 nd = link.from_node
260 if nd in done_nodes:
261 # Node already treated!
262 done_links.add(link)
263 elif link.to_socket == socket:
264 sorted_nodes.append(nd)
265 done_links.add(link)
266 done_nodes.add(nd)
267 input_links -= done_links
268 return sorted_nodes
271 def auto_align_nodes(node_tree):
272 """Given a shader node tree, arrange nodes neatly relative to the output node."""
273 x_gap = 200
274 y_gap = 180
275 nodes = node_tree.nodes
276 links = node_tree.links
277 output_node = None
278 for node in nodes:
279 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
280 output_node = node
281 break
283 else: # Just in case there is no output
284 return
286 def align(to_node):
287 from_nodes = get_input_nodes(to_node, links)
288 for i, node in enumerate(from_nodes):
289 node.location.x = min(node.location.x, to_node.location.x - x_gap)
290 node.location.y = to_node.location.y
291 node.location.y -= i * y_gap
292 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
293 align(node)
295 align(output_node)
298 def clean_node_tree(node_tree):
299 """Clear all nodes in a shader node tree except the output.
301 Returns the output node
303 nodes = node_tree.nodes
304 for node in list(nodes): # copy to avoid altering the loop's data source
305 if not node.type == 'OUTPUT_MATERIAL':
306 nodes.remove(node)
308 return node_tree.nodes[0]
311 def get_shadeless_node(dest_node_tree):
312 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
313 try:
314 node_tree = bpy.data.node_groups['IAP_SHADELESS']
316 except KeyError:
317 # need to build node shadeless node group
318 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
319 output_node = node_tree.nodes.new('NodeGroupOutput')
320 input_node = node_tree.nodes.new('NodeGroupInput')
322 node_tree.outputs.new('NodeSocketShader', 'Shader')
323 node_tree.inputs.new('NodeSocketColor', 'Color')
325 # This could be faster as a transparent shader, but then no ambient occlusion
326 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
327 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
329 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
330 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
332 light_path = node_tree.nodes.new('ShaderNodeLightPath')
333 is_glossy_ray = light_path.outputs['Is Glossy Ray']
334 is_shadow_ray = light_path.outputs['Is Shadow Ray']
335 ray_depth = light_path.outputs['Ray Depth']
336 transmission_depth = light_path.outputs['Transmission Depth']
338 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
339 unrefracted_depth.operation = 'SUBTRACT'
340 unrefracted_depth.label = 'Bounce Count'
341 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
342 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
344 refracted = node_tree.nodes.new('ShaderNodeMath')
345 refracted.operation = 'SUBTRACT'
346 refracted.label = 'Camera or Refracted'
347 refracted.inputs[0].default_value = 1.0
348 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
350 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
351 reflection_limit.operation = 'SUBTRACT'
352 reflection_limit.label = 'Limit Reflections'
353 reflection_limit.inputs[0].default_value = 2.0
354 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
356 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
357 camera_reflected.operation = 'MULTIPLY'
358 camera_reflected.label = 'Camera Ray to Glossy'
359 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
360 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
362 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
363 shadow_or_reflect.operation = 'MAXIMUM'
364 shadow_or_reflect.label = 'Shadow or Reflection?'
365 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
366 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
368 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
369 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
370 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
371 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
372 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
374 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
375 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
376 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
377 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
379 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
381 auto_align_nodes(node_tree)
383 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
384 group_node.node_tree = node_tree
386 return group_node
389 # -----------------------------------------------------------------------------
390 # Corner Pin Driver Helpers
392 @bpy.app.handlers.persistent
393 def check_drivers(*args, **kwargs):
394 """Check if watched objects in a scene have changed and trigger compositor update
396 This is part of a hack to ensure the compositor updates
397 itself when the objects used for drivers change.
399 It only triggers if transformation matricies change to avoid
400 a cyclic loop of updates.
402 if not watched_objects:
403 # if there is nothing to watch, don't bother running this
404 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
405 return
407 update = False
408 for name, matrix in list(watched_objects.items()):
409 try:
410 obj = bpy.data.objects[name]
411 except KeyError:
412 # The user must have removed this object
413 del watched_objects[name]
414 else:
415 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
416 if new_matrix != matrix:
417 watched_objects[name] = new_matrix
418 update = True
420 if update:
421 # Trick to re-evaluate drivers
422 bpy.context.scene.frame_current = bpy.context.scene.frame_current
425 def register_watched_object(obj):
426 """Register an object to be monitored for transformation changes"""
427 name = obj.name
429 # known object? -> we're done
430 if name in watched_objects:
431 return
433 if not watched_objects:
434 # make sure check_drivers is active
435 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
437 watched_objects[name] = None
440 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
441 """Find the location in camera space of a plane's corner"""
442 if args or kwargs:
443 # I've added args / kwargs as a compatibility measure with future versions
444 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
446 plane = bpy.data.objects[object_name]
448 # Passing in camera doesn't work before 2.78, so we use the current one
449 camera = camera or bpy.context.scene.camera
451 # Hack to ensure compositor updates on future changes
452 register_watched_object(camera)
453 register_watched_object(plane)
455 scale = plane.scale * 2.0
456 v = plane.dimensions.copy()
457 v.x *= x / scale.x
458 v.y *= y / scale.y
459 v = plane.matrix_world @ v
461 camera_vertex = world_to_camera_view(
462 bpy.context.scene, camera, v)
464 return camera_vertex[axis]
467 @bpy.app.handlers.persistent
468 def register_driver(*args, **kwargs):
469 """Register the find_plane_corner function for use with drivers"""
470 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
473 # -----------------------------------------------------------------------------
474 # Compositing Helpers
476 def group_in_frame(node_tree, name, nodes):
477 frame_node = node_tree.nodes.new("NodeFrame")
478 frame_node.label = name
479 frame_node.name = name + "_frame"
481 min_pos = Vector(nodes[0].location)
482 max_pos = min_pos.copy()
484 for node in nodes:
485 top_left = node.location
486 bottom_right = top_left + Vector((node.width, -node.height))
488 for i in (0, 1):
489 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
490 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
492 node.parent = frame_node
494 frame_node.width = max_pos[0] - min_pos[0] + 50
495 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
496 frame_node.shrink = True
498 return frame_node
501 def position_frame_bottom_left(node_tree, frame_node):
502 newpos = Vector((100000, 100000)) # start reasonably far top / right
504 # Align with the furthest left
505 for node in node_tree.nodes.values():
506 if node != frame_node and node.parent != frame_node:
507 newpos.x = min(newpos.x, node.location.x + 30)
509 # As high as we can get without overlapping anything to the right
510 for node in node_tree.nodes.values():
511 if node != frame_node and not node.parent:
512 if node.location.x < newpos.x + frame_node.width:
513 print("Below", node.name, node.location, node.height, node.dimensions)
514 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
516 frame_node.location = newpos
519 def setup_compositing(context, plane, img_spec):
520 # Node Groups only work with "new" dependency graph and even
521 # then it has some problems with not updating the first time
522 # So instead this groups with a node frame, which works reliably
524 scene = context.scene
525 scene.use_nodes = True
526 node_tree = scene.node_tree
527 name = plane.name
529 image_node = node_tree.nodes.new("CompositorNodeImage")
530 image_node.name = name + "_image"
531 image_node.image = img_spec.image
532 image_node.location = Vector((0, 0))
533 image_node.frame_start = img_spec.frame_start
534 image_node.frame_offset = img_spec.frame_offset
535 image_node.frame_duration = img_spec.frame_duration
537 scale_node = node_tree.nodes.new("CompositorNodeScale")
538 scale_node.name = name + "_scale"
539 scale_node.space = 'RENDER_SIZE'
540 scale_node.location = image_node.location + \
541 Vector((image_node.width + 20, 0))
542 scale_node.show_options = False
544 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
545 cornerpin_node.name = name + "_cornerpin"
546 cornerpin_node.location = scale_node.location + \
547 Vector((0, -scale_node.height))
549 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
550 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
552 # Put all the nodes in a frame for organization
553 frame_node = group_in_frame(
554 node_tree, name,
555 (image_node, scale_node, cornerpin_node)
558 # Position frame at bottom / left
559 position_frame_bottom_left(node_tree, frame_node)
561 # Configure Drivers
562 for corner in cornerpin_node.inputs[1:]:
563 id = corner.identifier
564 x = -1 if 'Left' in id else 1
565 y = -1 if 'Lower' in id else 1
566 drivers = corner.driver_add('default_value')
567 for i, axis_fcurve in enumerate(drivers):
568 driver = axis_fcurve.driver
569 # Always use the current camera
570 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
571 # Track camera location to ensure Deps Graph triggers (not used in the call)
572 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
573 # Don't break if the name changes
574 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
575 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
576 repr(plane.name),
577 x, y, i
579 driver.type = 'SCRIPTED'
580 driver.is_valid = True
581 axis_fcurve.is_valid = True
582 driver.expression = "%s" % driver.expression
584 context.view_layer.update()
587 # -----------------------------------------------------------------------------
588 # Operator
590 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
591 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
593 bl_idname = "import_image.to_plane"
594 bl_label = "Import Images as Planes"
595 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
597 # ----------------------
598 # File dialog properties
599 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
601 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
603 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
604 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
605 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
607 # ----------------------
608 # Properties - Importing
609 force_reload: BoolProperty(
610 name="Force Reload", default=False,
611 description="Force reloading of the image if already opened elsewhere in Blender"
614 image_sequence: BoolProperty(
615 name="Animate Image Sequences", default=False,
616 description="Import sequentially numbered images as an animated "
617 "image sequence instead of separate planes"
620 # -------------------------------------
621 # Properties - Position and Orientation
622 axis_id_to_vector = {
623 'X+': Vector(( 1, 0, 0)),
624 'Y+': Vector(( 0, 1, 0)),
625 'Z+': Vector(( 0, 0, 1)),
626 'X-': Vector((-1, 0, 0)),
627 'Y-': Vector(( 0, -1, 0)),
628 'Z-': Vector(( 0, 0, -1)),
631 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
633 OFFSET_MODES = (
634 ('X+', "X+", "Side by Side to the Left"),
635 ('Y+', "Y+", "Side by Side, Downward"),
636 ('Z+', "Z+", "Stacked Above"),
637 ('X-', "X-", "Side by Side to the Right"),
638 ('Y-', "Y-", "Side by Side, Upward"),
639 ('Z-', "Z-", "Stacked Below"),
641 offset_axis: EnumProperty(
642 name="Orientation", default='X+', items=OFFSET_MODES,
643 description="How planes are oriented relative to each others' local axis"
646 offset_amount: FloatProperty(
647 name="Offset", soft_min=0, default=0.1, description="Space between planes",
648 subtype='DISTANCE', unit='LENGTH'
651 AXIS_MODES = (
652 ('X+', "X+", "Facing Positive X"),
653 ('Y+', "Y+", "Facing Positive Y"),
654 ('Z+', "Z+ (Up)", "Facing Positive Z"),
655 ('X-', "X-", "Facing Negative X"),
656 ('Y-', "Y-", "Facing Negative Y"),
657 ('Z-', "Z- (Down)", "Facing Negative Z"),
658 ('CAM', "Face Camera", "Facing Camera"),
659 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
661 align_axis: EnumProperty(
662 name="Align", default='CAM_AX', items=AXIS_MODES,
663 description="How to align the planes"
665 # prev_align_axis is used only by update_size_model
666 prev_align_axis: EnumProperty(
667 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
668 align_track: BoolProperty(
669 name="Track Camera", default=False, description="Always face the camera"
672 # -----------------
673 # Properties - Size
674 def update_size_mode(self, context):
675 """If sizing relative to the camera, always face the camera"""
676 if self.size_mode == 'CAMERA':
677 self.prev_align_axis = self.align_axis
678 self.align_axis = 'CAM'
679 else:
680 # if a different alignment was set revert to that when
681 # size mode is changed
682 if self.prev_align_axis != 'NONE':
683 self.align_axis = self.prev_align_axis
684 self._prev_align_axis = 'NONE'
686 SIZE_MODES = (
687 ('ABSOLUTE', "Absolute", "Use absolute size"),
688 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
689 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
690 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
692 size_mode: EnumProperty(
693 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
694 update=update_size_mode,
695 description="How the size of the plane is computed")
697 FILL_MODES = (
698 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
699 ('FIT', "Fit", "Fit entire image within the camera frame"),
701 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
702 description="How large in the camera frame is the plane")
704 height: FloatProperty(name="Height", description="Height of the created plane",
705 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
707 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
708 description="Number of pixels per inch or Blender Unit")
710 # ------------------------------
711 # Properties - Material / Shader
712 SHADERS = (
713 ('PRINCIPLED',"Principled","Principled Shader"),
714 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
715 ('EMISSION', "Emit", "Emission Shader"),
717 shader: EnumProperty(name="Shader", items=SHADERS, default='PRINCIPLED', description="Node shader to use")
719 emit_strength: FloatProperty(
720 name="Strength", min=0.0, default=1.0, soft_max=10.0,
721 step=100, description="Brightness of Emission Texture")
723 overwrite_material: BoolProperty(
724 name="Overwrite Material", default=True,
725 description="Overwrite existing Material (based on material name)")
727 compositing_nodes: BoolProperty(
728 name="Setup Corner Pin", default=False,
729 description="Build Compositor Nodes to reference this image "
730 "without re-rendering")
732 # ------------------
733 # Properties - Image
734 use_transparency: BoolProperty(
735 name="Use Alpha", default=True,
736 description="Use alpha channel for transparency")
738 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
739 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
740 alpha_mode: EnumProperty(
741 name=t.name, items=alpha_mode_items, default=t.default,
742 description=t.description)
744 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
745 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
747 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
749 # -------
750 # Draw UI
751 def draw_import_config(self, context):
752 # --- Import Options --- #
753 layout = self.layout
754 box = layout.box()
756 box.label(text="Import Options:", icon='IMPORT')
757 row = box.row()
758 row.active = bpy.data.is_saved
759 row.prop(self, "relative")
761 box.prop(self, "force_reload")
762 box.prop(self, "image_sequence")
764 def draw_material_config(self, context):
765 # --- Material / Rendering Properties --- #
766 layout = self.layout
767 box = layout.box()
769 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
770 box.prop(self, "compositing_nodes")
772 box.label(text="Material Settings:", icon='MATERIAL')
774 row = box.row()
775 row.prop(self, 'shader', expand=True)
776 if self.shader == 'EMISSION':
777 box.prop(self, "emit_strength")
779 engine = context.scene.render.engine
780 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
781 box.label(text="%s is not supported" % engine, icon='ERROR')
783 box.prop(self, "overwrite_material")
785 box.label(text="Texture Settings:", icon='TEXTURE')
786 row = box.row()
787 row.prop(self, "use_transparency")
788 sub = row.row()
789 sub.active = self.use_transparency
790 sub.prop(self, "alpha_mode", text="")
791 box.prop(self, "use_auto_refresh")
793 def draw_spatial_config(self, context):
794 # --- Spatial Properties: Position, Size and Orientation --- #
795 layout = self.layout
796 box = layout.box()
798 box.label(text="Position:", icon='SNAP_GRID')
799 box.prop(self, "offset")
800 col = box.column()
801 row = col.row()
802 row.prop(self, "offset_axis", expand=True)
803 row = col.row()
804 row.prop(self, "offset_amount")
805 col.enabled = self.offset
807 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
808 row = box.row()
809 row.prop(self, "size_mode", expand=True)
810 if self.size_mode == 'ABSOLUTE':
811 box.prop(self, "height")
812 elif self.size_mode == 'CAMERA':
813 row = box.row()
814 row.prop(self, "fill_mode", expand=True)
815 else:
816 box.prop(self, "factor")
818 box.label(text="Orientation:")
819 row = box.row()
820 row.enabled = 'CAM' not in self.size_mode
821 row.prop(self, "align_axis")
822 row = box.row()
823 row.enabled = 'CAM' in self.align_axis
824 row.alignment = 'RIGHT'
825 row.prop(self, "align_track")
827 def draw(self, context):
829 # Draw configuration sections
830 self.draw_import_config(context)
831 self.draw_material_config(context)
832 self.draw_spatial_config(context)
834 # -------------------------------------------------------------------------
835 # Core functionality
836 def invoke(self, context, event):
837 engine = context.scene.render.engine
838 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
839 if engine != 'BLENDER_WORKBENCH':
840 self.report({'ERROR'}, "Cannot generate materials for unknown %s render engine" % engine)
841 return {'CANCELLED'}
842 else:
843 self.report({'WARNING'},
844 "Generating Cycles/EEVEE compatible material, but won't be visible with %s engine" % engine)
846 # Open file browser
847 context.window_manager.fileselect_add(self)
848 return {'RUNNING_MODAL'}
850 def execute(self, context):
851 if not bpy.data.is_saved:
852 self.relative = False
854 # this won't work in edit mode
855 editmode = context.preferences.edit.use_enter_edit_mode
856 context.preferences.edit.use_enter_edit_mode = False
857 if context.active_object and context.active_object.mode != 'OBJECT':
858 bpy.ops.object.mode_set(mode='OBJECT')
860 self.import_images(context)
862 context.preferences.edit.use_enter_edit_mode = editmode
864 return {'FINISHED'}
866 def import_images(self, context):
868 # load images / sequences
869 images = tuple(load_images(
870 (fn.name for fn in self.files),
871 self.directory,
872 force_reload=self.force_reload,
873 find_sequences=self.image_sequence
876 # Create individual planes
877 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
879 context.view_layer.update()
881 # Align planes relative to each other
882 if self.offset:
883 offset_axis = self.axis_id_to_vector[self.offset_axis]
884 offset_planes(planes, self.offset_amount, offset_axis)
886 if self.size_mode == 'CAMERA' and offset_axis.z:
887 for plane in planes:
888 x, y = compute_camera_size(
889 context, plane.location,
890 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
891 plane.dimensions = x, y, 0.0
893 # setup new selection
894 for plane in planes:
895 plane.select_set(True)
897 # all done!
898 self.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes)))
900 # operate on a single image
901 def single_image_spec_to_plane(self, context, img_spec):
903 # Configure image
904 self.apply_image_options(img_spec.image)
906 # Configure material
907 engine = context.scene.render.engine
908 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
909 material = self.create_cycles_material(context, img_spec)
911 # Create and position plane object
912 plane = self.create_image_plane(context, material.name, img_spec)
914 # Assign Material
915 plane.data.materials.append(material)
917 # If applicable, setup Corner Pin node
918 if self.compositing_nodes:
919 setup_compositing(context, plane, img_spec)
921 return plane
923 def apply_image_options(self, image):
924 if self.use_transparency == False:
925 image.alpha_mode = 'NONE'
926 else:
927 image.alpha_mode = self.alpha_mode
929 if self.relative:
930 try: # can't always find the relative path (between drive letters on windows)
931 image.filepath = bpy.path.relpath(image.filepath)
932 except ValueError:
933 pass
935 def apply_texture_options(self, texture, img_spec):
936 # Shared by both Cycles and Blender Internal
937 image_user = texture.image_user
938 image_user.use_auto_refresh = self.use_auto_refresh
939 image_user.frame_start = img_spec.frame_start
940 image_user.frame_offset = img_spec.frame_offset
941 image_user.frame_duration = img_spec.frame_duration
943 # Image sequences need auto refresh to display reliably
944 if img_spec.image.source == 'SEQUENCE':
945 image_user.use_auto_refresh = True
947 texture.extension = 'CLIP' # Default of "Repeat" can cause artifacts
949 def apply_material_options(self, material, slot):
950 shader = self.shader
952 if self.use_transparency:
953 material.alpha = 0.0
954 material.specular_alpha = 0.0
955 slot.use_map_alpha = True
956 else:
957 material.alpha = 1.0
958 material.specular_alpha = 1.0
959 slot.use_map_alpha = False
961 material.specular_intensity = 0
962 material.diffuse_intensity = 1.0
963 material.use_transparency = self.use_transparency
964 material.transparency_method = 'Z_TRANSPARENCY'
965 material.use_shadeless = (shader == 'SHADELESS')
966 material.use_transparent_shadows = (shader == 'DIFFUSE')
967 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
969 # -------------------------------------------------------------------------
970 # Cycles/Eevee
971 def create_cycles_texnode(self, context, node_tree, img_spec):
972 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
973 tex_image.image = img_spec.image
974 tex_image.show_texture = True
975 self.apply_texture_options(tex_image, img_spec)
976 return tex_image
978 def create_cycles_material(self, context, img_spec):
979 image = img_spec.image
980 name_compat = bpy.path.display_name_from_filepath(image.filepath)
981 material = None
982 if self.overwrite_material:
983 for mat in bpy.data.materials:
984 if mat.name == name_compat:
985 material = mat
986 if not material:
987 material = bpy.data.materials.new(name=name_compat)
989 material.use_nodes = True
990 if self.use_transparency:
991 material.blend_method = 'BLEND'
992 node_tree = material.node_tree
993 out_node = clean_node_tree(node_tree)
995 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
997 if self.shader == 'PRINCIPLED':
998 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
999 elif self.shader == 'SHADELESS':
1000 core_shader = get_shadeless_node(node_tree)
1001 elif self.shader == 'EMISSION':
1002 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1003 core_shader.inputs['Emission Strength'].default_value = self.emit_strength
1004 core_shader.inputs['Base Color'].default_value = (0.0, 0.0, 0.0, 1.0)
1005 core_shader.inputs['Specular'].default_value = 0.0
1007 # Connect color from texture
1008 if self.shader in {'PRINCIPLED', 'SHADELESS'}:
1009 node_tree.links.new(core_shader.inputs[0], tex_image.outputs['Color'])
1010 elif self.shader == 'EMISSION':
1011 node_tree.links.new(core_shader.inputs['Emission'], tex_image.outputs['Color'])
1013 if self.use_transparency:
1014 if self.shader in {'PRINCIPLED', 'EMISSION'}:
1015 node_tree.links.new(core_shader.inputs['Alpha'], tex_image.outputs['Alpha'])
1016 else:
1017 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1019 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1020 node_tree.links.new(mix_shader.inputs['Fac'], tex_image.outputs['Alpha'])
1021 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs['BSDF'])
1022 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1023 core_shader = mix_shader
1025 node_tree.links.new(out_node.inputs['Surface'], core_shader.outputs[0])
1027 auto_align_nodes(node_tree)
1028 return material
1030 # -------------------------------------------------------------------------
1031 # Geometry Creation
1032 def create_image_plane(self, context, name, img_spec):
1034 width, height = self.compute_plane_size(context, img_spec)
1036 # Create new mesh
1037 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1038 plane = context.active_object
1039 # Why does mesh.primitive_plane_add leave the object in edit mode???
1040 if plane.mode != 'OBJECT':
1041 bpy.ops.object.mode_set(mode='OBJECT')
1042 plane.dimensions = width, height, 0.0
1043 plane.data.name = plane.name = name
1044 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1046 # If sizing for camera, also insert into the camera's field of view
1047 if self.size_mode == 'CAMERA':
1048 offset_axis = self.axis_id_to_vector[self.offset_axis]
1049 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1050 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1052 self.align_plane(context, plane)
1054 return plane
1056 def compute_plane_size(self, context, img_spec):
1057 """Given the image size in pixels and location, determine size of plane"""
1058 px, py = img_spec.size
1060 # can't load data
1061 if px == 0 or py == 0:
1062 px = py = 1
1064 if self.size_mode == 'ABSOLUTE':
1065 y = self.height
1066 x = px / py * y
1068 elif self.size_mode == 'CAMERA':
1069 x, y = compute_camera_size(
1070 context, context.scene.cursor.location,
1071 self.fill_mode, px / py
1074 elif self.size_mode == 'DPI':
1075 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1076 x = px * fact
1077 y = py * fact
1079 else: # elif self.size_mode == 'DPBU'
1080 fact = 1 / self.factor
1081 x = px * fact
1082 y = py * fact
1084 return x, y
1086 def align_plane(self, context, plane):
1087 """Pick an axis and align the plane to it"""
1088 if 'CAM' in self.align_axis:
1089 # Camera-aligned
1090 camera = context.scene.camera
1091 if (camera):
1092 # Find the axis that best corresponds to the camera's view direction
1093 axis = camera.matrix_world @ \
1094 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1095 # pick the axis with the greatest magnitude
1096 mag = max(map(abs, axis))
1097 # And use that axis & direction
1098 axis = Vector([
1099 n / mag if abs(n) == mag else 0.0
1100 for n in axis
1102 else:
1103 # No camera? Just face Z axis
1104 axis = Vector((0, 0, 1))
1105 self.align_axis = 'Z+'
1106 else:
1107 # Axis-aligned
1108 axis = self.axis_id_to_vector[self.align_axis]
1110 # rotate accordingly for x/y axiis
1111 if not axis.z:
1112 plane.rotation_euler.x = pi / 2
1114 if axis.y > 0:
1115 plane.rotation_euler.z = pi
1116 elif axis.y < 0:
1117 plane.rotation_euler.z = 0
1118 elif axis.x > 0:
1119 plane.rotation_euler.z = pi / 2
1120 elif axis.x < 0:
1121 plane.rotation_euler.z = -pi / 2
1123 # or flip 180 degrees for negative z
1124 elif axis.z < 0:
1125 plane.rotation_euler.y = pi
1127 if self.align_axis == 'CAM':
1128 constraint = plane.constraints.new('COPY_ROTATION')
1129 constraint.target = camera
1130 constraint.use_x = constraint.use_y = constraint.use_z = True
1131 if not self.align_track:
1132 bpy.ops.object.visual_transform_apply()
1133 plane.constraints.clear()
1135 if self.align_axis == 'CAM_AX' and self.align_track:
1136 constraint = plane.constraints.new('LOCKED_TRACK')
1137 constraint.target = camera
1138 constraint.track_axis = 'TRACK_Z'
1139 constraint.lock_axis = 'LOCK_Y'
1142 # -----------------------------------------------------------------------------
1143 # Register
1145 def import_images_button(self, context):
1146 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1149 classes = (
1150 IMPORT_IMAGE_OT_to_plane,
1154 def register():
1155 for cls in classes:
1156 bpy.utils.register_class(cls)
1158 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1159 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1161 bpy.app.handlers.load_post.append(register_driver)
1162 register_driver()
1165 def unregister():
1166 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1167 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1169 # This will only exist if drivers are active
1170 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1171 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1173 bpy.app.handlers.load_post.remove(register_driver)
1174 del bpy.app.driver_namespace['import_image__find_plane_corner']
1176 for cls in classes:
1177 bpy.utils.unregister_class(cls)
1180 if __name__ == "__main__":
1181 # Run simple doc tests
1182 import doctest
1183 doctest.testmod()
1185 unregister()
1186 register()