Rigify: add a warning if feature set requires newer Blender version
[blender-addons.git] / io_import_images_as_planes.py
blob2adc8a12316f92c481521eb800835f233434701d
1 # SPDX-FileCopyrightText: 2010-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 bl_info = {
6 "name": "Import Images as Planes",
7 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc), mrbimax",
8 "version": (3, 5, 0),
9 "blender": (2, 91, 0),
10 "location": "File > Import > Images as Planes or Add > Image > 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 bpy.app.translations import pgettext_tip as tip_
29 from mathutils import Vector
31 from bpy.props import (
32 StringProperty,
33 BoolProperty,
34 EnumProperty,
35 FloatProperty,
36 CollectionProperty,
39 from bpy_extras.object_utils import (
40 AddObjectHelper,
41 world_to_camera_view,
44 from bpy_extras.image_utils import load_image
46 # -----------------------------------------------------------------------------
47 # Module-level Shared State
49 watched_objects = {} # used to trigger compositor updates on scene updates
52 # -----------------------------------------------------------------------------
53 # Misc utils.
55 def add_driver_prop(driver, name, type, id, path):
56 """Configure a new driver variable."""
57 dv = driver.variables.new()
58 dv.name = name
59 dv.type = 'SINGLE_PROP'
60 target = dv.targets[0]
61 target.id_type = type
62 target.id = id
63 target.data_path = path
66 # -----------------------------------------------------------------------------
67 # Image loading
69 ImageSpec = namedtuple(
70 'ImageSpec',
71 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
73 num_regex = re.compile('[0-9]') # Find a single number
74 nums_regex = re.compile('[0-9]+') # Find a set of numbers
77 def find_image_sequences(files):
78 """From a group of files, detect image sequences.
80 This returns a generator of tuples, which contain the filename,
81 start frame, and length of the detected sequence
83 >>> list(find_image_sequences([
84 ... "test2-001.jp2", "test2-002.jp2",
85 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
86 ... "blaah"]))
87 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
89 """
90 files = iter(sorted(files))
91 prev_file = None
92 pattern = ""
93 matches = []
94 segment = None
95 length = 1
96 for filename in files:
97 new_pattern = num_regex.sub('#', filename)
98 new_matches = list(map(int, nums_regex.findall(filename)))
99 if new_pattern == pattern:
100 # this file looks like it may be in sequence from the previous
102 # if there are multiple sets of numbers, figure out what changed
103 if segment is None:
104 for i, prev, cur in zip(count(), matches, new_matches):
105 if prev != cur:
106 segment = i
107 break
109 # did it only change by one?
110 for i, prev, cur in zip(count(), matches, new_matches):
111 if i == segment:
112 # We expect this to increment
113 prev = prev + length
114 if prev != cur:
115 break
117 # All good!
118 else:
119 length += 1
120 continue
122 # No continuation -> spit out what we found and reset counters
123 if prev_file:
124 if length > 1:
125 yield prev_file, matches[segment], length
126 else:
127 yield prev_file, 1, 1
129 prev_file = filename
130 matches = new_matches
131 pattern = new_pattern
132 segment = None
133 length = 1
135 if prev_file:
136 if length > 1:
137 yield prev_file, matches[segment], length
138 else:
139 yield prev_file, 1, 1
142 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
143 """Wrapper for bpy's load_image
145 Loads a set of images, movies, or even image sequences
146 Returns a generator of ImageSpec wrapper objects later used for texture setup
148 if find_sequences: # if finding sequences, we need some pre-processing first
149 file_iter = find_image_sequences(filenames)
150 else:
151 file_iter = zip(filenames, repeat(1), repeat(1))
153 for filename, offset, frames in file_iter:
154 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
156 # Size is unavailable for sequences, so we grab it early
157 size = tuple(image.size)
159 if image.source == 'MOVIE':
160 # Blender BPY BUG!
161 # This number is only valid when read a second time in 2.77
162 # This repeated line is not a mistake
163 frames = image.frame_duration
164 frames = image.frame_duration
166 elif frames > 1: # Not movie, but multiple frames -> image sequence
167 image.source = 'SEQUENCE'
169 yield ImageSpec(image, size, frame_start, offset - 1, frames)
172 # -----------------------------------------------------------------------------
173 # Position & Size Helpers
175 def offset_planes(planes, gap, axis):
176 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
178 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
179 obj2 0.5 blender units away from obj1 along the local positive Z axis.
181 This is in local space, not world space, so all planes should share
182 a common scale and rotation.
184 prior = planes[0]
185 offset = Vector()
186 for current in planes[1:]:
187 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
189 offset += local_offset * axis
190 current.location = current.matrix_world @ offset
192 prior = current
195 def compute_camera_size(context, center, fill_mode, aspect):
196 """Determine how large an object needs to be to fit or fill the camera's field of view."""
197 scene = context.scene
198 camera = scene.camera
199 view_frame = camera.data.view_frame(scene=scene)
200 frame_size = \
201 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
202 Vector([min(v[i] for v in view_frame) for i in range(3)])
203 camera_aspect = frame_size.x / frame_size.y
205 # Convert the frame size to the correct sizing at a given distance
206 if camera.type == 'ORTHO':
207 frame_size = frame_size.xy
208 else:
209 # Perspective transform
210 distance = world_to_camera_view(scene, camera, center).z
211 frame_size = distance * frame_size.xy / (-view_frame[0].z)
213 # Determine what axis to match to the camera
214 match_axis = 0 # match the Y axis size
215 match_aspect = aspect
216 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
217 (fill_mode == 'FIT' and aspect < camera_aspect):
218 match_axis = 1 # match the X axis size
219 match_aspect = 1.0 / aspect
221 # scale the other axis to the correct aspect
222 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
224 return frame_size
227 def center_in_camera(scene, camera, obj, axis=(1, 1)):
228 """Center object along specified axis of the camera"""
229 camera_matrix_col = camera.matrix_world.col
230 location = obj.location
232 # Vector from the camera's world coordinate center to the object's center
233 delta = camera_matrix_col[3].xyz - location
235 # How far off center we are along the camera's local X
236 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
237 # How far off center we are along the camera's local Y
238 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
240 # Now offset only along camera local axis
241 offset = camera_matrix_col[0].xyz * camera_x_mag + \
242 camera_matrix_col[1].xyz * camera_y_mag
244 obj.location = location + offset
247 # -----------------------------------------------------------------------------
248 # Cycles/Eevee utils
250 def get_input_nodes(node, links):
251 """Get nodes that are a inputs to the given node"""
252 # Get all links going to node.
253 input_links = {lnk for lnk in links if lnk.to_node == node}
254 # Sort those links, get their input nodes (and avoid doubles!).
255 sorted_nodes = []
256 done_nodes = set()
257 for socket in node.inputs:
258 done_links = set()
259 for link in input_links:
260 nd = link.from_node
261 if nd in done_nodes:
262 # Node already treated!
263 done_links.add(link)
264 elif link.to_socket == socket:
265 sorted_nodes.append(nd)
266 done_links.add(link)
267 done_nodes.add(nd)
268 input_links -= done_links
269 return sorted_nodes
272 def auto_align_nodes(node_tree):
273 """Given a shader node tree, arrange nodes neatly relative to the output node."""
274 x_gap = 200
275 y_gap = 180
276 nodes = node_tree.nodes
277 links = node_tree.links
278 output_node = None
279 for node in nodes:
280 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
281 output_node = node
282 break
284 else: # Just in case there is no output
285 return
287 def align(to_node):
288 from_nodes = get_input_nodes(to_node, links)
289 for i, node in enumerate(from_nodes):
290 node.location.x = min(node.location.x, to_node.location.x - x_gap)
291 node.location.y = to_node.location.y
292 node.location.y -= i * y_gap
293 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
294 align(node)
296 align(output_node)
299 def clean_node_tree(node_tree):
300 """Clear all nodes in a shader node tree except the output.
302 Returns the output node
304 nodes = node_tree.nodes
305 for node in list(nodes): # copy to avoid altering the loop's data source
306 if not node.type == 'OUTPUT_MATERIAL':
307 nodes.remove(node)
309 return node_tree.nodes[0]
312 def get_shadeless_node(dest_node_tree):
313 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
314 try:
315 node_tree = bpy.data.node_groups['IAP_SHADELESS']
317 except KeyError:
318 # need to build node shadeless node group
319 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
320 output_node = node_tree.nodes.new('NodeGroupOutput')
321 input_node = node_tree.nodes.new('NodeGroupInput')
323 node_tree.outputs.new('NodeSocketShader', 'Shader')
324 node_tree.inputs.new('NodeSocketColor', 'Color')
326 # This could be faster as a transparent shader, but then no ambient occlusion
327 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
328 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
330 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
331 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
333 light_path = node_tree.nodes.new('ShaderNodeLightPath')
334 is_glossy_ray = light_path.outputs['Is Glossy Ray']
335 is_shadow_ray = light_path.outputs['Is Shadow Ray']
336 ray_depth = light_path.outputs['Ray Depth']
337 transmission_depth = light_path.outputs['Transmission Depth']
339 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
340 unrefracted_depth.operation = 'SUBTRACT'
341 unrefracted_depth.label = 'Bounce Count'
342 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
343 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
345 refracted = node_tree.nodes.new('ShaderNodeMath')
346 refracted.operation = 'SUBTRACT'
347 refracted.label = 'Camera or Refracted'
348 refracted.inputs[0].default_value = 1.0
349 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
351 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
352 reflection_limit.operation = 'SUBTRACT'
353 reflection_limit.label = 'Limit Reflections'
354 reflection_limit.inputs[0].default_value = 2.0
355 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
357 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
358 camera_reflected.operation = 'MULTIPLY'
359 camera_reflected.label = 'Camera Ray to Glossy'
360 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
361 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
363 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
364 shadow_or_reflect.operation = 'MAXIMUM'
365 shadow_or_reflect.label = 'Shadow or Reflection?'
366 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
367 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
369 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
370 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
371 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
372 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
373 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
375 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
376 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
377 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
378 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
380 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
382 auto_align_nodes(node_tree)
384 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
385 group_node.node_tree = node_tree
387 return group_node
390 # -----------------------------------------------------------------------------
391 # Corner Pin Driver Helpers
393 @bpy.app.handlers.persistent
394 def check_drivers(*args, **kwargs):
395 """Check if watched objects in a scene have changed and trigger compositor update
397 This is part of a hack to ensure the compositor updates
398 itself when the objects used for drivers change.
400 It only triggers if transformation matricies change to avoid
401 a cyclic loop of updates.
403 if not watched_objects:
404 # if there is nothing to watch, don't bother running this
405 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
406 return
408 update = False
409 for name, matrix in list(watched_objects.items()):
410 try:
411 obj = bpy.data.objects[name]
412 except KeyError:
413 # The user must have removed this object
414 del watched_objects[name]
415 else:
416 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
417 if new_matrix != matrix:
418 watched_objects[name] = new_matrix
419 update = True
421 if update:
422 # Trick to re-evaluate drivers
423 bpy.context.scene.frame_current = bpy.context.scene.frame_current
426 def register_watched_object(obj):
427 """Register an object to be monitored for transformation changes"""
428 name = obj.name
430 # known object? -> we're done
431 if name in watched_objects:
432 return
434 if not watched_objects:
435 # make sure check_drivers is active
436 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
438 watched_objects[name] = None
441 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
442 """Find the location in camera space of a plane's corner"""
443 if args or kwargs:
444 # I've added args / kwargs as a compatibility measure with future versions
445 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
447 plane = bpy.data.objects[object_name]
449 # Passing in camera doesn't work before 2.78, so we use the current one
450 camera = camera or bpy.context.scene.camera
452 # Hack to ensure compositor updates on future changes
453 register_watched_object(camera)
454 register_watched_object(plane)
456 scale = plane.scale * 2.0
457 v = plane.dimensions.copy()
458 v.x *= x / scale.x
459 v.y *= y / scale.y
460 v = plane.matrix_world @ v
462 camera_vertex = world_to_camera_view(
463 bpy.context.scene, camera, v)
465 return camera_vertex[axis]
468 @bpy.app.handlers.persistent
469 def register_driver(*args, **kwargs):
470 """Register the find_plane_corner function for use with drivers"""
471 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
474 # -----------------------------------------------------------------------------
475 # Compositing Helpers
477 def group_in_frame(node_tree, name, nodes):
478 frame_node = node_tree.nodes.new("NodeFrame")
479 frame_node.label = name
480 frame_node.name = name + "_frame"
482 min_pos = Vector(nodes[0].location)
483 max_pos = min_pos.copy()
485 for node in nodes:
486 top_left = node.location
487 bottom_right = top_left + Vector((node.width, -node.height))
489 for i in (0, 1):
490 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
491 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
493 node.parent = frame_node
495 frame_node.width = max_pos[0] - min_pos[0] + 50
496 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
497 frame_node.shrink = True
499 return frame_node
502 def position_frame_bottom_left(node_tree, frame_node):
503 newpos = Vector((100000, 100000)) # start reasonably far top / right
505 # Align with the furthest left
506 for node in node_tree.nodes.values():
507 if node != frame_node and node.parent != frame_node:
508 newpos.x = min(newpos.x, node.location.x + 30)
510 # As high as we can get without overlapping anything to the right
511 for node in node_tree.nodes.values():
512 if node != frame_node and not node.parent:
513 if node.location.x < newpos.x + frame_node.width:
514 print("Below", node.name, node.location, node.height, node.dimensions)
515 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
517 frame_node.location = newpos
520 def setup_compositing(context, plane, img_spec):
521 # Node Groups only work with "new" dependency graph and even
522 # then it has some problems with not updating the first time
523 # So instead this groups with a node frame, which works reliably
525 scene = context.scene
526 scene.use_nodes = True
527 node_tree = scene.node_tree
528 name = plane.name
530 image_node = node_tree.nodes.new("CompositorNodeImage")
531 image_node.name = name + "_image"
532 image_node.image = img_spec.image
533 image_node.location = Vector((0, 0))
534 image_node.frame_start = img_spec.frame_start
535 image_node.frame_offset = img_spec.frame_offset
536 image_node.frame_duration = img_spec.frame_duration
538 scale_node = node_tree.nodes.new("CompositorNodeScale")
539 scale_node.name = name + "_scale"
540 scale_node.space = 'RENDER_SIZE'
541 scale_node.location = image_node.location + \
542 Vector((image_node.width + 20, 0))
543 scale_node.show_options = False
545 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
546 cornerpin_node.name = name + "_cornerpin"
547 cornerpin_node.location = scale_node.location + \
548 Vector((0, -scale_node.height))
550 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
551 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
553 # Put all the nodes in a frame for organization
554 frame_node = group_in_frame(
555 node_tree, name,
556 (image_node, scale_node, cornerpin_node)
559 # Position frame at bottom / left
560 position_frame_bottom_left(node_tree, frame_node)
562 # Configure Drivers
563 for corner in cornerpin_node.inputs[1:]:
564 id = corner.identifier
565 x = -1 if 'Left' in id else 1
566 y = -1 if 'Lower' in id else 1
567 drivers = corner.driver_add('default_value')
568 for i, axis_fcurve in enumerate(drivers):
569 driver = axis_fcurve.driver
570 # Always use the current camera
571 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
572 # Track camera location to ensure Deps Graph triggers (not used in the call)
573 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
574 # Don't break if the name changes
575 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
576 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
577 repr(plane.name),
578 x, y, i
580 driver.type = 'SCRIPTED'
581 driver.is_valid = True
582 axis_fcurve.is_valid = True
583 driver.expression = "%s" % driver.expression
585 context.view_layer.update()
588 # -----------------------------------------------------------------------------
589 # Operator
591 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
592 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
594 bl_idname = "import_image.to_plane"
595 bl_label = "Import Images as Planes"
596 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
598 # ----------------------
599 # File dialog properties
600 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
602 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
604 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
605 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
606 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
608 # ----------------------
609 # Properties - Importing
610 force_reload: BoolProperty(
611 name="Force Reload", default=False,
612 description="Force reloading of the image if already opened elsewhere in Blender"
615 image_sequence: BoolProperty(
616 name="Animate Image Sequences", default=False,
617 description="Import sequentially numbered images as an animated "
618 "image sequence instead of separate planes"
621 # -------------------------------------
622 # Properties - Position and Orientation
623 axis_id_to_vector = {
624 'X+': Vector(( 1, 0, 0)),
625 'Y+': Vector(( 0, 1, 0)),
626 'Z+': Vector(( 0, 0, 1)),
627 'X-': Vector((-1, 0, 0)),
628 'Y-': Vector(( 0, -1, 0)),
629 'Z-': Vector(( 0, 0, -1)),
632 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
634 OFFSET_MODES = (
635 ('X+', "X+", "Side by Side to the Left"),
636 ('Y+', "Y+", "Side by Side, Downward"),
637 ('Z+', "Z+", "Stacked Above"),
638 ('X-', "X-", "Side by Side to the Right"),
639 ('Y-', "Y-", "Side by Side, Upward"),
640 ('Z-', "Z-", "Stacked Below"),
642 offset_axis: EnumProperty(
643 name="Orientation", default='X+', items=OFFSET_MODES,
644 description="How planes are oriented relative to each others' local axis"
647 offset_amount: FloatProperty(
648 name="Offset", soft_min=0, default=0.1, description="Space between planes",
649 subtype='DISTANCE', unit='LENGTH'
652 AXIS_MODES = (
653 ('X+', "X+", "Facing Positive X"),
654 ('Y+', "Y+", "Facing Positive Y"),
655 ('Z+', "Z+ (Up)", "Facing Positive Z"),
656 ('X-', "X-", "Facing Negative X"),
657 ('Y-', "Y-", "Facing Negative Y"),
658 ('Z-', "Z- (Down)", "Facing Negative Z"),
659 ('CAM', "Face Camera", "Facing Camera"),
660 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
662 align_axis: EnumProperty(
663 name="Align", default='CAM_AX', items=AXIS_MODES,
664 description="How to align the planes"
666 # prev_align_axis is used only by update_size_model
667 prev_align_axis: EnumProperty(
668 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
669 align_track: BoolProperty(
670 name="Track Camera", default=False, description="Always face the camera"
673 # -----------------
674 # Properties - Size
675 def update_size_mode(self, context):
676 """If sizing relative to the camera, always face the camera"""
677 if self.size_mode == 'CAMERA':
678 self.prev_align_axis = self.align_axis
679 self.align_axis = 'CAM'
680 else:
681 # if a different alignment was set revert to that when
682 # size mode is changed
683 if self.prev_align_axis != 'NONE':
684 self.align_axis = self.prev_align_axis
685 self._prev_align_axis = 'NONE'
687 SIZE_MODES = (
688 ('ABSOLUTE', "Absolute", "Use absolute size"),
689 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
690 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
691 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
693 size_mode: EnumProperty(
694 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
695 update=update_size_mode,
696 description="How the size of the plane is computed")
698 FILL_MODES = (
699 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
700 ('FIT', "Fit", "Fit entire image within the camera frame"),
702 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
703 description="How large in the camera frame is the plane")
705 height: FloatProperty(name="Height", description="Height of the created plane",
706 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
708 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
709 description="Number of pixels per inch or Blender Unit")
711 # ------------------------------
712 # Properties - Material / Shader
713 SHADERS = (
714 ('PRINCIPLED',"Principled","Principled Shader"),
715 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
716 ('EMISSION', "Emit", "Emission Shader"),
718 shader: EnumProperty(name="Shader", items=SHADERS, default='PRINCIPLED', description="Node shader to use")
720 emit_strength: FloatProperty(
721 name="Strength", min=0.0, default=1.0, soft_max=10.0,
722 step=100, description="Brightness of Emission Texture")
724 use_transparency: BoolProperty(
725 name="Use Alpha", default=True,
726 description="Use alpha channel for transparency")
728 BLEND_METHODS = (
729 ('BLEND',"Blend","Render polygon transparent, depending on alpha channel of the texture"),
730 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
731 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
732 ('OPAQUE', "Opaque","Render surface without transparency"),
734 blend_method: EnumProperty(name="Blend Mode", items=BLEND_METHODS, default='BLEND', description="Blend Mode for Transparent Faces")
736 SHADOW_METHODS = (
737 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
738 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
739 ('OPAQUE',"Opaque","Material will cast shadows without transparency"),
740 ('NONE',"None","Material will cast no shadow"),
742 shadow_method: EnumProperty(name="Shadow Mode", items=SHADOW_METHODS, default='CLIP', description="Shadow mapping method")
744 use_backface_culling: BoolProperty(
745 name="Backface Culling", default=False,
746 description="Use back face culling to hide the back side of faces")
748 show_transparent_back: BoolProperty(
749 name="Show Backface", default=True,
750 description="Render multiple transparent layers (may introduce transparency sorting problems)")
752 overwrite_material: BoolProperty(
753 name="Overwrite Material", default=True,
754 description="Overwrite existing Material (based on material name)")
756 compositing_nodes: BoolProperty(
757 name="Setup Corner Pin", default=False,
758 description="Build Compositor Nodes to reference this image "
759 "without re-rendering")
761 # ------------------
762 # Properties - Image
763 INTERPOLATION_MODES = (
764 ('Linear', "Linear", "Linear interpolation"),
765 ('Closest', "Closest", "No interpolation (sample closest texel)"),
766 ('Cubic', "Cubic", "Cubic interpolation"),
767 ('Smart', "Smart", "Bicubic when magnifying, else bilinear (OSL only)"),
769 interpolation: EnumProperty(name="Interpolation", items=INTERPOLATION_MODES, default='Linear', description="Texture interpolation")
771 EXTENSION_MODES = (
772 ('CLIP', "Clip", "Clip to image size and set exterior pixels as transparent"),
773 ('EXTEND', "Extend", "Extend by repeating edge pixels of the image"),
774 ('REPEAT', "Repeat", "Cause the image to repeat horizontally and vertically"),
776 extension: EnumProperty(name="Extension", items=EXTENSION_MODES, default='CLIP', description="How the image is extrapolated past its original bounds")
778 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
779 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
780 alpha_mode: EnumProperty(
781 name=t.name, items=alpha_mode_items, default=t.default,
782 description=t.description)
784 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
785 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
787 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
789 # -------
790 # Draw UI
791 def draw_import_config(self, context):
792 # --- Import Options --- #
793 layout = self.layout
794 box = layout.box()
796 box.label(text="Import Options:", icon='IMPORT')
797 row = box.row()
798 row.active = bpy.data.is_saved
799 row.prop(self, "relative")
801 box.prop(self, "force_reload")
802 box.prop(self, "image_sequence")
804 def draw_material_config(self, context):
805 # --- Material / Rendering Properties --- #
806 layout = self.layout
807 box = layout.box()
809 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
810 box.prop(self, "compositing_nodes")
811 layout = self.layout
812 box = layout.box()
813 box.label(text="Material Settings:", icon='MATERIAL')
815 box.label(text="Material Type")
816 row = box.row()
817 row.prop(self, 'shader', expand=True)
818 if self.shader == 'EMISSION':
819 box.prop(self, "emit_strength")
821 box.label(text="Blend Mode")
822 row = box.row()
823 row.prop(self, 'blend_method', expand=True)
824 if self.use_transparency and self.alpha_mode != "NONE" and self.blend_method == "OPAQUE":
825 box.label(text="'Opaque' does not support alpha", icon="ERROR")
826 if self.blend_method == 'BLEND':
827 row = box.row()
828 row.prop(self, "show_transparent_back")
830 box.label(text="Shadow Mode")
831 row = box.row()
832 row.prop(self, 'shadow_method', expand=True)
834 row = box.row()
835 row.prop(self, "use_backface_culling")
837 engine = context.scene.render.engine
838 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
839 box.label(text=tip_("%s is not supported") % engine, icon='ERROR')
841 box.prop(self, "overwrite_material")
842 layout = self.layout
843 box = layout.box()
844 box.label(text="Texture Settings:", icon='TEXTURE')
845 box.label(text="Interpolation")
846 row = box.row()
847 row.prop(self, 'interpolation', expand=True)
848 box.label(text="Extension")
849 row = box.row()
850 row.prop(self, 'extension', expand=True)
851 row = box.row()
852 row.prop(self, "use_transparency")
853 if self.use_transparency:
854 sub = row.row()
855 sub.prop(self, "alpha_mode", text="")
856 row = box.row()
857 row.prop(self, "use_auto_refresh")
859 def draw_spatial_config(self, context):
860 # --- Spatial Properties: Position, Size and Orientation --- #
861 layout = self.layout
862 box = layout.box()
864 box.label(text="Position:", icon='SNAP_GRID')
865 box.prop(self, "offset")
866 col = box.column()
867 row = col.row()
868 row.prop(self, "offset_axis", expand=True)
869 row = col.row()
870 row.prop(self, "offset_amount")
871 col.enabled = self.offset
873 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
874 row = box.row()
875 row.prop(self, "size_mode", expand=True)
876 if self.size_mode == 'ABSOLUTE':
877 box.prop(self, "height")
878 elif self.size_mode == 'CAMERA':
879 row = box.row()
880 row.prop(self, "fill_mode", expand=True)
881 else:
882 box.prop(self, "factor")
884 box.label(text="Orientation:")
885 row = box.row()
886 row.enabled = 'CAM' not in self.size_mode
887 row.prop(self, "align_axis")
888 row = box.row()
889 row.enabled = 'CAM' in self.align_axis
890 row.alignment = 'RIGHT'
891 row.prop(self, "align_track")
893 def draw(self, context):
895 # Draw configuration sections
896 self.draw_import_config(context)
897 self.draw_material_config(context)
898 self.draw_spatial_config(context)
900 # -------------------------------------------------------------------------
901 # Core functionality
902 def invoke(self, context, event):
903 engine = context.scene.render.engine
904 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
905 if engine != 'BLENDER_WORKBENCH':
906 self.report({'ERROR'}, tip_("Cannot generate materials for unknown %s render engine") % engine)
907 return {'CANCELLED'}
908 else:
909 self.report({'WARNING'},
910 tip_("Generating Cycles/EEVEE compatible material, but won't be visible with %s engine") % engine)
912 # Open file browser
913 context.window_manager.fileselect_add(self)
914 return {'RUNNING_MODAL'}
916 def execute(self, context):
917 if not bpy.data.is_saved:
918 self.relative = False
920 # this won't work in edit mode
921 editmode = context.preferences.edit.use_enter_edit_mode
922 context.preferences.edit.use_enter_edit_mode = False
923 if context.active_object and context.active_object.mode != 'OBJECT':
924 bpy.ops.object.mode_set(mode='OBJECT')
926 self.import_images(context)
928 context.preferences.edit.use_enter_edit_mode = editmode
930 return {'FINISHED'}
932 def import_images(self, context):
934 # load images / sequences
935 images = tuple(load_images(
936 (fn.name for fn in self.files),
937 self.directory,
938 force_reload=self.force_reload,
939 find_sequences=self.image_sequence
942 # Create individual planes
943 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
945 context.view_layer.update()
947 # Align planes relative to each other
948 if self.offset:
949 offset_axis = self.axis_id_to_vector[self.offset_axis]
950 offset_planes(planes, self.offset_amount, offset_axis)
952 if self.size_mode == 'CAMERA' and offset_axis.z:
953 for plane in planes:
954 x, y = compute_camera_size(
955 context, plane.location,
956 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
957 plane.dimensions = x, y, 0.0
959 # setup new selection
960 for plane in planes:
961 plane.select_set(True)
963 # all done!
964 self.report({'INFO'}, tip_("Added {} Image Plane(s)").format(len(planes)))
966 # operate on a single image
967 def single_image_spec_to_plane(self, context, img_spec):
969 # Configure image
970 self.apply_image_options(img_spec.image)
972 # Configure material
973 engine = context.scene.render.engine
974 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
975 material = self.create_cycles_material(context, img_spec)
977 # Create and position plane object
978 plane = self.create_image_plane(context, material.name, img_spec)
980 # Assign Material
981 plane.data.materials.append(material)
983 # If applicable, setup Corner Pin node
984 if self.compositing_nodes:
985 setup_compositing(context, plane, img_spec)
987 return plane
989 def apply_image_options(self, image):
990 if self.use_transparency == False:
991 image.alpha_mode = 'NONE'
992 else:
993 image.alpha_mode = self.alpha_mode
995 if self.relative:
996 try: # can't always find the relative path (between drive letters on windows)
997 image.filepath = bpy.path.relpath(image.filepath)
998 except ValueError:
999 pass
1001 def apply_texture_options(self, texture, img_spec):
1002 # Shared by both Cycles and Blender Internal
1003 image_user = texture.image_user
1004 image_user.use_auto_refresh = self.use_auto_refresh
1005 image_user.frame_start = img_spec.frame_start
1006 image_user.frame_offset = img_spec.frame_offset
1007 image_user.frame_duration = img_spec.frame_duration
1009 # Image sequences need auto refresh to display reliably
1010 if img_spec.image.source == 'SEQUENCE':
1011 image_user.use_auto_refresh = True
1013 def apply_material_options(self, material, slot):
1014 shader = self.shader
1016 if self.use_transparency:
1017 material.alpha = 0.0
1018 material.specular_alpha = 0.0
1019 slot.use_map_alpha = True
1020 else:
1021 material.alpha = 1.0
1022 material.specular_alpha = 1.0
1023 slot.use_map_alpha = False
1025 material.specular_intensity = 0
1026 material.diffuse_intensity = 1.0
1027 material.use_transparency = self.use_transparency
1028 material.transparency_method = 'Z_TRANSPARENCY'
1029 material.use_shadeless = (shader == 'SHADELESS')
1030 material.use_transparent_shadows = (shader == 'DIFFUSE')
1031 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
1033 # -------------------------------------------------------------------------
1034 # Cycles/Eevee
1035 def create_cycles_texnode(self, context, node_tree, img_spec):
1036 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
1037 tex_image.image = img_spec.image
1038 tex_image.show_texture = True
1039 tex_image.interpolation = self.interpolation
1040 tex_image.extension = self.extension
1041 self.apply_texture_options(tex_image, img_spec)
1042 return tex_image
1044 def create_cycles_material(self, context, img_spec):
1045 image = img_spec.image
1046 name_compat = bpy.path.display_name_from_filepath(image.filepath)
1047 material = None
1048 if self.overwrite_material:
1049 for mat in bpy.data.materials:
1050 if mat.name == name_compat:
1051 material = mat
1052 if not material:
1053 material = bpy.data.materials.new(name=name_compat)
1055 material.use_nodes = True
1057 material.blend_method = self.blend_method
1058 material.shadow_method = self.shadow_method
1060 material.use_backface_culling = self.use_backface_culling
1061 material.show_transparent_back = self.show_transparent_back
1063 node_tree = material.node_tree
1064 out_node = clean_node_tree(node_tree)
1066 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1068 if self.shader == 'PRINCIPLED':
1069 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1070 elif self.shader == 'SHADELESS':
1071 core_shader = get_shadeless_node(node_tree)
1072 elif self.shader == 'EMISSION':
1073 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1074 core_shader.inputs['Emission Strength'].default_value = self.emit_strength
1075 core_shader.inputs['Base Color'].default_value = (0.0, 0.0, 0.0, 1.0)
1076 core_shader.inputs['Specular'].default_value = 0.0
1078 # Connect color from texture
1079 if self.shader in {'PRINCIPLED', 'SHADELESS'}:
1080 node_tree.links.new(core_shader.inputs[0], tex_image.outputs['Color'])
1081 elif self.shader == 'EMISSION':
1082 node_tree.links.new(core_shader.inputs['Emission'], tex_image.outputs['Color'])
1084 if self.use_transparency:
1085 if self.shader in {'PRINCIPLED', 'EMISSION'}:
1086 node_tree.links.new(core_shader.inputs['Alpha'], tex_image.outputs['Alpha'])
1087 else:
1088 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1090 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1091 node_tree.links.new(mix_shader.inputs['Fac'], tex_image.outputs['Alpha'])
1092 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs['BSDF'])
1093 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1094 core_shader = mix_shader
1096 node_tree.links.new(out_node.inputs['Surface'], core_shader.outputs[0])
1098 auto_align_nodes(node_tree)
1099 return material
1101 # -------------------------------------------------------------------------
1102 # Geometry Creation
1103 def create_image_plane(self, context, name, img_spec):
1105 width, height = self.compute_plane_size(context, img_spec)
1107 # Create new mesh
1108 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1109 plane = context.active_object
1110 # Why does mesh.primitive_plane_add leave the object in edit mode???
1111 if plane.mode != 'OBJECT':
1112 bpy.ops.object.mode_set(mode='OBJECT')
1113 plane.dimensions = width, height, 0.0
1114 plane.data.name = plane.name = name
1115 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1117 # If sizing for camera, also insert into the camera's field of view
1118 if self.size_mode == 'CAMERA':
1119 offset_axis = self.axis_id_to_vector[self.offset_axis]
1120 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1121 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1123 self.align_plane(context, plane)
1125 return plane
1127 def compute_plane_size(self, context, img_spec):
1128 """Given the image size in pixels and location, determine size of plane"""
1129 px, py = img_spec.size
1131 # can't load data
1132 if px == 0 or py == 0:
1133 px = py = 1
1135 if self.size_mode == 'ABSOLUTE':
1136 y = self.height
1137 x = px / py * y
1139 elif self.size_mode == 'CAMERA':
1140 x, y = compute_camera_size(
1141 context, context.scene.cursor.location,
1142 self.fill_mode, px / py
1145 elif self.size_mode == 'DPI':
1146 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1147 x = px * fact
1148 y = py * fact
1150 else: # elif self.size_mode == 'DPBU'
1151 fact = 1 / self.factor
1152 x = px * fact
1153 y = py * fact
1155 return x, y
1157 def align_plane(self, context, plane):
1158 """Pick an axis and align the plane to it"""
1159 if 'CAM' in self.align_axis:
1160 # Camera-aligned
1161 camera = context.scene.camera
1162 if (camera):
1163 # Find the axis that best corresponds to the camera's view direction
1164 axis = camera.matrix_world @ \
1165 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1166 # pick the axis with the greatest magnitude
1167 mag = max(map(abs, axis))
1168 # And use that axis & direction
1169 axis = Vector([
1170 n / mag if abs(n) == mag else 0.0
1171 for n in axis
1173 else:
1174 # No camera? Just face Z axis
1175 axis = Vector((0, 0, 1))
1176 self.align_axis = 'Z+'
1177 else:
1178 # Axis-aligned
1179 axis = self.axis_id_to_vector[self.align_axis]
1181 # rotate accordingly for x/y axiis
1182 if not axis.z:
1183 plane.rotation_euler.x = pi / 2
1185 if axis.y > 0:
1186 plane.rotation_euler.z = pi
1187 elif axis.y < 0:
1188 plane.rotation_euler.z = 0
1189 elif axis.x > 0:
1190 plane.rotation_euler.z = pi / 2
1191 elif axis.x < 0:
1192 plane.rotation_euler.z = -pi / 2
1194 # or flip 180 degrees for negative z
1195 elif axis.z < 0:
1196 plane.rotation_euler.y = pi
1198 if self.align_axis == 'CAM':
1199 constraint = plane.constraints.new('COPY_ROTATION')
1200 constraint.target = camera
1201 constraint.use_x = constraint.use_y = constraint.use_z = True
1202 if not self.align_track:
1203 bpy.ops.object.visual_transform_apply()
1204 plane.constraints.clear()
1206 if self.align_axis == 'CAM_AX' and self.align_track:
1207 constraint = plane.constraints.new('LOCKED_TRACK')
1208 constraint.target = camera
1209 constraint.track_axis = 'TRACK_Z'
1210 constraint.lock_axis = 'LOCK_Y'
1213 # -----------------------------------------------------------------------------
1214 # Register
1216 def import_images_button(self, context):
1217 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1220 classes = (
1221 IMPORT_IMAGE_OT_to_plane,
1225 def register():
1226 for cls in classes:
1227 bpy.utils.register_class(cls)
1229 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1230 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1232 bpy.app.handlers.load_post.append(register_driver)
1233 register_driver()
1236 def unregister():
1237 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1238 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1240 # This will only exist if drivers are active
1241 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1242 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1244 bpy.app.handlers.load_post.remove(register_driver)
1245 del bpy.app.driver_namespace['import_image__find_plane_corner']
1247 for cls in classes:
1248 bpy.utils.unregister_class(cls)
1251 if __name__ == "__main__":
1252 # Run simple doc tests
1253 import doctest
1254 doctest.testmod()
1256 unregister()
1257 register()