system_demo_mode: minor usability improvements
[blender-addons.git] / io_import_images_as_planes.py
blob8a5590eb74a3d93331b27f2c791f769d2cc4523b
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 bl_info = {
4 "name": "Import Images as Planes",
5 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc), mrbimax",
6 "version": (3, 5, 0),
7 "blender": (2, 91, 0),
8 "location": "File > Import > Images as Planes or Add > Image > Images as Planes",
9 "description": "Imports images and creates planes with the appropriate aspect ratio. "
10 "The images are mapped to the planes.",
11 "warning": "",
12 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/images_as_planes.html",
13 "support": 'OFFICIAL',
14 "category": "Import-Export",
17 import os
18 import warnings
19 import re
20 from itertools import count, repeat
21 from collections import namedtuple
22 from math import pi
24 import bpy
25 from bpy.types import Operator
26 from bpy.app.translations import pgettext_tip as tip_
27 from mathutils import Vector
29 from bpy.props import (
30 StringProperty,
31 BoolProperty,
32 EnumProperty,
33 FloatProperty,
34 CollectionProperty,
37 from bpy_extras.object_utils import (
38 AddObjectHelper,
39 world_to_camera_view,
42 from bpy_extras.image_utils import load_image
44 # -----------------------------------------------------------------------------
45 # Module-level Shared State
47 watched_objects = {} # used to trigger compositor updates on scene updates
50 # -----------------------------------------------------------------------------
51 # Misc utils.
53 def add_driver_prop(driver, name, type, id, path):
54 """Configure a new driver variable."""
55 dv = driver.variables.new()
56 dv.name = name
57 dv.type = 'SINGLE_PROP'
58 target = dv.targets[0]
59 target.id_type = type
60 target.id = id
61 target.data_path = path
64 # -----------------------------------------------------------------------------
65 # Image loading
67 ImageSpec = namedtuple(
68 'ImageSpec',
69 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
71 num_regex = re.compile('[0-9]') # Find a single number
72 nums_regex = re.compile('[0-9]+') # Find a set of numbers
75 def find_image_sequences(files):
76 """From a group of files, detect image sequences.
78 This returns a generator of tuples, which contain the filename,
79 start frame, and length of the detected sequence
81 >>> list(find_image_sequences([
82 ... "test2-001.jp2", "test2-002.jp2",
83 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
84 ... "blaah"]))
85 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
87 """
88 files = iter(sorted(files))
89 prev_file = None
90 pattern = ""
91 matches = []
92 segment = None
93 length = 1
94 for filename in files:
95 new_pattern = num_regex.sub('#', filename)
96 new_matches = list(map(int, nums_regex.findall(filename)))
97 if new_pattern == pattern:
98 # this file looks like it may be in sequence from the previous
100 # if there are multiple sets of numbers, figure out what changed
101 if segment is None:
102 for i, prev, cur in zip(count(), matches, new_matches):
103 if prev != cur:
104 segment = i
105 break
107 # did it only change by one?
108 for i, prev, cur in zip(count(), matches, new_matches):
109 if i == segment:
110 # We expect this to increment
111 prev = prev + length
112 if prev != cur:
113 break
115 # All good!
116 else:
117 length += 1
118 continue
120 # No continuation -> spit out what we found and reset counters
121 if prev_file:
122 if length > 1:
123 yield prev_file, matches[segment], length
124 else:
125 yield prev_file, 1, 1
127 prev_file = filename
128 matches = new_matches
129 pattern = new_pattern
130 segment = None
131 length = 1
133 if prev_file:
134 if length > 1:
135 yield prev_file, matches[segment], length
136 else:
137 yield prev_file, 1, 1
140 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
141 """Wrapper for bpy's load_image
143 Loads a set of images, movies, or even image sequences
144 Returns a generator of ImageSpec wrapper objects later used for texture setup
146 if find_sequences: # if finding sequences, we need some pre-processing first
147 file_iter = find_image_sequences(filenames)
148 else:
149 file_iter = zip(filenames, repeat(1), repeat(1))
151 for filename, offset, frames in file_iter:
152 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
154 # Size is unavailable for sequences, so we grab it early
155 size = tuple(image.size)
157 if image.source == 'MOVIE':
158 # Blender BPY BUG!
159 # This number is only valid when read a second time in 2.77
160 # This repeated line is not a mistake
161 frames = image.frame_duration
162 frames = image.frame_duration
164 elif frames > 1: # Not movie, but multiple frames -> image sequence
165 image.source = 'SEQUENCE'
167 yield ImageSpec(image, size, frame_start, offset - 1, frames)
170 # -----------------------------------------------------------------------------
171 # Position & Size Helpers
173 def offset_planes(planes, gap, axis):
174 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
176 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
177 obj2 0.5 blender units away from obj1 along the local positive Z axis.
179 This is in local space, not world space, so all planes should share
180 a common scale and rotation.
182 prior = planes[0]
183 offset = Vector()
184 for current in planes[1:]:
185 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
187 offset += local_offset * axis
188 current.location = current.matrix_world @ offset
190 prior = current
193 def compute_camera_size(context, center, fill_mode, aspect):
194 """Determine how large an object needs to be to fit or fill the camera's field of view."""
195 scene = context.scene
196 camera = scene.camera
197 view_frame = camera.data.view_frame(scene=scene)
198 frame_size = \
199 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
200 Vector([min(v[i] for v in view_frame) for i in range(3)])
201 camera_aspect = frame_size.x / frame_size.y
203 # Convert the frame size to the correct sizing at a given distance
204 if camera.type == 'ORTHO':
205 frame_size = frame_size.xy
206 else:
207 # Perspective transform
208 distance = world_to_camera_view(scene, camera, center).z
209 frame_size = distance * frame_size.xy / (-view_frame[0].z)
211 # Determine what axis to match to the camera
212 match_axis = 0 # match the Y axis size
213 match_aspect = aspect
214 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
215 (fill_mode == 'FIT' and aspect < camera_aspect):
216 match_axis = 1 # match the X axis size
217 match_aspect = 1.0 / aspect
219 # scale the other axis to the correct aspect
220 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
222 return frame_size
225 def center_in_camera(scene, camera, obj, axis=(1, 1)):
226 """Center object along specified axis of the camera"""
227 camera_matrix_col = camera.matrix_world.col
228 location = obj.location
230 # Vector from the camera's world coordinate center to the object's center
231 delta = camera_matrix_col[3].xyz - location
233 # How far off center we are along the camera's local X
234 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
235 # How far off center we are along the camera's local Y
236 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
238 # Now offset only along camera local axis
239 offset = camera_matrix_col[0].xyz * camera_x_mag + \
240 camera_matrix_col[1].xyz * camera_y_mag
242 obj.location = location + offset
245 # -----------------------------------------------------------------------------
246 # Cycles/Eevee utils
248 def get_input_nodes(node, links):
249 """Get nodes that are a inputs to the given node"""
250 # Get all links going to node.
251 input_links = {lnk for lnk in links if lnk.to_node == node}
252 # Sort those links, get their input nodes (and avoid doubles!).
253 sorted_nodes = []
254 done_nodes = set()
255 for socket in node.inputs:
256 done_links = set()
257 for link in input_links:
258 nd = link.from_node
259 if nd in done_nodes:
260 # Node already treated!
261 done_links.add(link)
262 elif link.to_socket == socket:
263 sorted_nodes.append(nd)
264 done_links.add(link)
265 done_nodes.add(nd)
266 input_links -= done_links
267 return sorted_nodes
270 def auto_align_nodes(node_tree):
271 """Given a shader node tree, arrange nodes neatly relative to the output node."""
272 x_gap = 200
273 y_gap = 180
274 nodes = node_tree.nodes
275 links = node_tree.links
276 output_node = None
277 for node in nodes:
278 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
279 output_node = node
280 break
282 else: # Just in case there is no output
283 return
285 def align(to_node):
286 from_nodes = get_input_nodes(to_node, links)
287 for i, node in enumerate(from_nodes):
288 node.location.x = min(node.location.x, to_node.location.x - x_gap)
289 node.location.y = to_node.location.y
290 node.location.y -= i * y_gap
291 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
292 align(node)
294 align(output_node)
297 def clean_node_tree(node_tree):
298 """Clear all nodes in a shader node tree except the output.
300 Returns the output node
302 nodes = node_tree.nodes
303 for node in list(nodes): # copy to avoid altering the loop's data source
304 if not node.type == 'OUTPUT_MATERIAL':
305 nodes.remove(node)
307 return node_tree.nodes[0]
310 def get_shadeless_node(dest_node_tree):
311 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
312 try:
313 node_tree = bpy.data.node_groups['IAP_SHADELESS']
315 except KeyError:
316 # need to build node shadeless node group
317 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
318 output_node = node_tree.nodes.new('NodeGroupOutput')
319 input_node = node_tree.nodes.new('NodeGroupInput')
321 node_tree.outputs.new('NodeSocketShader', 'Shader')
322 node_tree.inputs.new('NodeSocketColor', 'Color')
324 # This could be faster as a transparent shader, but then no ambient occlusion
325 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
326 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
328 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
329 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
331 light_path = node_tree.nodes.new('ShaderNodeLightPath')
332 is_glossy_ray = light_path.outputs['Is Glossy Ray']
333 is_shadow_ray = light_path.outputs['Is Shadow Ray']
334 ray_depth = light_path.outputs['Ray Depth']
335 transmission_depth = light_path.outputs['Transmission Depth']
337 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
338 unrefracted_depth.operation = 'SUBTRACT'
339 unrefracted_depth.label = 'Bounce Count'
340 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
341 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
343 refracted = node_tree.nodes.new('ShaderNodeMath')
344 refracted.operation = 'SUBTRACT'
345 refracted.label = 'Camera or Refracted'
346 refracted.inputs[0].default_value = 1.0
347 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
349 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
350 reflection_limit.operation = 'SUBTRACT'
351 reflection_limit.label = 'Limit Reflections'
352 reflection_limit.inputs[0].default_value = 2.0
353 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
355 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
356 camera_reflected.operation = 'MULTIPLY'
357 camera_reflected.label = 'Camera Ray to Glossy'
358 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
359 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
361 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
362 shadow_or_reflect.operation = 'MAXIMUM'
363 shadow_or_reflect.label = 'Shadow or Reflection?'
364 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
365 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
367 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
368 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
369 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
370 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
371 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
373 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
374 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
375 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
376 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
378 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
380 auto_align_nodes(node_tree)
382 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
383 group_node.node_tree = node_tree
385 return group_node
388 # -----------------------------------------------------------------------------
389 # Corner Pin Driver Helpers
391 @bpy.app.handlers.persistent
392 def check_drivers(*args, **kwargs):
393 """Check if watched objects in a scene have changed and trigger compositor update
395 This is part of a hack to ensure the compositor updates
396 itself when the objects used for drivers change.
398 It only triggers if transformation matricies change to avoid
399 a cyclic loop of updates.
401 if not watched_objects:
402 # if there is nothing to watch, don't bother running this
403 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
404 return
406 update = False
407 for name, matrix in list(watched_objects.items()):
408 try:
409 obj = bpy.data.objects[name]
410 except KeyError:
411 # The user must have removed this object
412 del watched_objects[name]
413 else:
414 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
415 if new_matrix != matrix:
416 watched_objects[name] = new_matrix
417 update = True
419 if update:
420 # Trick to re-evaluate drivers
421 bpy.context.scene.frame_current = bpy.context.scene.frame_current
424 def register_watched_object(obj):
425 """Register an object to be monitored for transformation changes"""
426 name = obj.name
428 # known object? -> we're done
429 if name in watched_objects:
430 return
432 if not watched_objects:
433 # make sure check_drivers is active
434 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
436 watched_objects[name] = None
439 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
440 """Find the location in camera space of a plane's corner"""
441 if args or kwargs:
442 # I've added args / kwargs as a compatibility measure with future versions
443 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
445 plane = bpy.data.objects[object_name]
447 # Passing in camera doesn't work before 2.78, so we use the current one
448 camera = camera or bpy.context.scene.camera
450 # Hack to ensure compositor updates on future changes
451 register_watched_object(camera)
452 register_watched_object(plane)
454 scale = plane.scale * 2.0
455 v = plane.dimensions.copy()
456 v.x *= x / scale.x
457 v.y *= y / scale.y
458 v = plane.matrix_world @ v
460 camera_vertex = world_to_camera_view(
461 bpy.context.scene, camera, v)
463 return camera_vertex[axis]
466 @bpy.app.handlers.persistent
467 def register_driver(*args, **kwargs):
468 """Register the find_plane_corner function for use with drivers"""
469 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
472 # -----------------------------------------------------------------------------
473 # Compositing Helpers
475 def group_in_frame(node_tree, name, nodes):
476 frame_node = node_tree.nodes.new("NodeFrame")
477 frame_node.label = name
478 frame_node.name = name + "_frame"
480 min_pos = Vector(nodes[0].location)
481 max_pos = min_pos.copy()
483 for node in nodes:
484 top_left = node.location
485 bottom_right = top_left + Vector((node.width, -node.height))
487 for i in (0, 1):
488 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
489 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
491 node.parent = frame_node
493 frame_node.width = max_pos[0] - min_pos[0] + 50
494 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
495 frame_node.shrink = True
497 return frame_node
500 def position_frame_bottom_left(node_tree, frame_node):
501 newpos = Vector((100000, 100000)) # start reasonably far top / right
503 # Align with the furthest left
504 for node in node_tree.nodes.values():
505 if node != frame_node and node.parent != frame_node:
506 newpos.x = min(newpos.x, node.location.x + 30)
508 # As high as we can get without overlapping anything to the right
509 for node in node_tree.nodes.values():
510 if node != frame_node and not node.parent:
511 if node.location.x < newpos.x + frame_node.width:
512 print("Below", node.name, node.location, node.height, node.dimensions)
513 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
515 frame_node.location = newpos
518 def setup_compositing(context, plane, img_spec):
519 # Node Groups only work with "new" dependency graph and even
520 # then it has some problems with not updating the first time
521 # So instead this groups with a node frame, which works reliably
523 scene = context.scene
524 scene.use_nodes = True
525 node_tree = scene.node_tree
526 name = plane.name
528 image_node = node_tree.nodes.new("CompositorNodeImage")
529 image_node.name = name + "_image"
530 image_node.image = img_spec.image
531 image_node.location = Vector((0, 0))
532 image_node.frame_start = img_spec.frame_start
533 image_node.frame_offset = img_spec.frame_offset
534 image_node.frame_duration = img_spec.frame_duration
536 scale_node = node_tree.nodes.new("CompositorNodeScale")
537 scale_node.name = name + "_scale"
538 scale_node.space = 'RENDER_SIZE'
539 scale_node.location = image_node.location + \
540 Vector((image_node.width + 20, 0))
541 scale_node.show_options = False
543 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
544 cornerpin_node.name = name + "_cornerpin"
545 cornerpin_node.location = scale_node.location + \
546 Vector((0, -scale_node.height))
548 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
549 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
551 # Put all the nodes in a frame for organization
552 frame_node = group_in_frame(
553 node_tree, name,
554 (image_node, scale_node, cornerpin_node)
557 # Position frame at bottom / left
558 position_frame_bottom_left(node_tree, frame_node)
560 # Configure Drivers
561 for corner in cornerpin_node.inputs[1:]:
562 id = corner.identifier
563 x = -1 if 'Left' in id else 1
564 y = -1 if 'Lower' in id else 1
565 drivers = corner.driver_add('default_value')
566 for i, axis_fcurve in enumerate(drivers):
567 driver = axis_fcurve.driver
568 # Always use the current camera
569 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
570 # Track camera location to ensure Deps Graph triggers (not used in the call)
571 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
572 # Don't break if the name changes
573 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
574 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
575 repr(plane.name),
576 x, y, i
578 driver.type = 'SCRIPTED'
579 driver.is_valid = True
580 axis_fcurve.is_valid = True
581 driver.expression = "%s" % driver.expression
583 context.view_layer.update()
586 # -----------------------------------------------------------------------------
587 # Operator
589 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
590 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
592 bl_idname = "import_image.to_plane"
593 bl_label = "Import Images as Planes"
594 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
596 # ----------------------
597 # File dialog properties
598 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
600 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
602 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
603 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
604 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
606 # ----------------------
607 # Properties - Importing
608 force_reload: BoolProperty(
609 name="Force Reload", default=False,
610 description="Force reloading of the image if already opened elsewhere in Blender"
613 image_sequence: BoolProperty(
614 name="Animate Image Sequences", default=False,
615 description="Import sequentially numbered images as an animated "
616 "image sequence instead of separate planes"
619 # -------------------------------------
620 # Properties - Position and Orientation
621 axis_id_to_vector = {
622 'X+': Vector(( 1, 0, 0)),
623 'Y+': Vector(( 0, 1, 0)),
624 'Z+': Vector(( 0, 0, 1)),
625 'X-': Vector((-1, 0, 0)),
626 'Y-': Vector(( 0, -1, 0)),
627 'Z-': Vector(( 0, 0, -1)),
630 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
632 OFFSET_MODES = (
633 ('X+', "X+", "Side by Side to the Left"),
634 ('Y+', "Y+", "Side by Side, Downward"),
635 ('Z+', "Z+", "Stacked Above"),
636 ('X-', "X-", "Side by Side to the Right"),
637 ('Y-', "Y-", "Side by Side, Upward"),
638 ('Z-', "Z-", "Stacked Below"),
640 offset_axis: EnumProperty(
641 name="Orientation", default='X+', items=OFFSET_MODES,
642 description="How planes are oriented relative to each others' local axis"
645 offset_amount: FloatProperty(
646 name="Offset", soft_min=0, default=0.1, description="Space between planes",
647 subtype='DISTANCE', unit='LENGTH'
650 AXIS_MODES = (
651 ('X+', "X+", "Facing Positive X"),
652 ('Y+', "Y+", "Facing Positive Y"),
653 ('Z+', "Z+ (Up)", "Facing Positive Z"),
654 ('X-', "X-", "Facing Negative X"),
655 ('Y-', "Y-", "Facing Negative Y"),
656 ('Z-', "Z- (Down)", "Facing Negative Z"),
657 ('CAM', "Face Camera", "Facing Camera"),
658 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
660 align_axis: EnumProperty(
661 name="Align", default='CAM_AX', items=AXIS_MODES,
662 description="How to align the planes"
664 # prev_align_axis is used only by update_size_model
665 prev_align_axis: EnumProperty(
666 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
667 align_track: BoolProperty(
668 name="Track Camera", default=False, description="Always face the camera"
671 # -----------------
672 # Properties - Size
673 def update_size_mode(self, context):
674 """If sizing relative to the camera, always face the camera"""
675 if self.size_mode == 'CAMERA':
676 self.prev_align_axis = self.align_axis
677 self.align_axis = 'CAM'
678 else:
679 # if a different alignment was set revert to that when
680 # size mode is changed
681 if self.prev_align_axis != 'NONE':
682 self.align_axis = self.prev_align_axis
683 self._prev_align_axis = 'NONE'
685 SIZE_MODES = (
686 ('ABSOLUTE', "Absolute", "Use absolute size"),
687 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
688 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
689 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
691 size_mode: EnumProperty(
692 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
693 update=update_size_mode,
694 description="How the size of the plane is computed")
696 FILL_MODES = (
697 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
698 ('FIT', "Fit", "Fit entire image within the camera frame"),
700 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
701 description="How large in the camera frame is the plane")
703 height: FloatProperty(name="Height", description="Height of the created plane",
704 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
706 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
707 description="Number of pixels per inch or Blender Unit")
709 # ------------------------------
710 # Properties - Material / Shader
711 SHADERS = (
712 ('PRINCIPLED',"Principled","Principled Shader"),
713 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
714 ('EMISSION', "Emit", "Emission Shader"),
716 shader: EnumProperty(name="Shader", items=SHADERS, default='PRINCIPLED', description="Node shader to use")
718 emit_strength: FloatProperty(
719 name="Strength", min=0.0, default=1.0, soft_max=10.0,
720 step=100, description="Brightness of Emission Texture")
722 use_transparency: BoolProperty(
723 name="Use Alpha", default=True,
724 description="Use alpha channel for transparency")
726 BLEND_METHODS = (
727 ('BLEND',"Blend","Render polygon transparent, depending on alpha channel of the texture"),
728 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
729 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
730 ('OPAQUE', "Opaque","Render surface without transparency"),
732 blend_method: EnumProperty(name="Blend Mode", items=BLEND_METHODS, default='BLEND', description="Blend Mode for Transparent Faces")
734 SHADOW_METHODS = (
735 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
736 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
737 ('OPAQUE',"Opaque","Material will cast shadows without transparency"),
738 ('NONE',"None","Material will cast no shadow"),
740 shadow_method: EnumProperty(name="Shadow Mode", items=SHADOW_METHODS, default='CLIP', description="Shadow mapping method")
742 use_backface_culling: BoolProperty(
743 name="Backface Culling", default=False,
744 description="Use back face culling to hide the back side of faces")
746 show_transparent_back: BoolProperty(
747 name="Show Backface", default=True,
748 description="Render multiple transparent layers (may introduce transparency sorting problems)")
750 overwrite_material: BoolProperty(
751 name="Overwrite Material", default=True,
752 description="Overwrite existing Material (based on material name)")
754 compositing_nodes: BoolProperty(
755 name="Setup Corner Pin", default=False,
756 description="Build Compositor Nodes to reference this image "
757 "without re-rendering")
759 # ------------------
760 # Properties - Image
761 INTERPOLATION_MODES = (
762 ('Linear', "Linear", "Linear interpolation"),
763 ('Closest', "Closest", "No interpolation (sample closest texel)"),
764 ('Cubic', "Cubic", "Cubic interpolation"),
765 ('Smart', "Smart", "Bicubic when magnifying, else bilinear (OSL only)"),
767 interpolation: EnumProperty(name="Interpolation", items=INTERPOLATION_MODES, default='Linear', description="Texture interpolation")
769 EXTENSION_MODES = (
770 ('CLIP', "Clip", "Clip to image size and set exterior pixels as transparent"),
771 ('EXTEND', "Extend", "Extend by repeating edge pixels of the image"),
772 ('REPEAT', "Repeat", "Cause the image to repeat horizontally and vertically"),
774 extension: EnumProperty(name="Extension", items=EXTENSION_MODES, default='CLIP', description="How the image is extrapolated past its original bounds")
776 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
777 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
778 alpha_mode: EnumProperty(
779 name=t.name, items=alpha_mode_items, default=t.default,
780 description=t.description)
782 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
783 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
785 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
787 # -------
788 # Draw UI
789 def draw_import_config(self, context):
790 # --- Import Options --- #
791 layout = self.layout
792 box = layout.box()
794 box.label(text="Import Options:", icon='IMPORT')
795 row = box.row()
796 row.active = bpy.data.is_saved
797 row.prop(self, "relative")
799 box.prop(self, "force_reload")
800 box.prop(self, "image_sequence")
802 def draw_material_config(self, context):
803 # --- Material / Rendering Properties --- #
804 layout = self.layout
805 box = layout.box()
807 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
808 box.prop(self, "compositing_nodes")
809 layout = self.layout
810 box = layout.box()
811 box.label(text="Material Settings:", icon='MATERIAL')
813 box.label(text="Material Type")
814 row = box.row()
815 row.prop(self, 'shader', expand=True)
816 if self.shader == 'EMISSION':
817 box.prop(self, "emit_strength")
819 box.label(text="Blend Mode")
820 row = box.row()
821 row.prop(self, 'blend_method', expand=True)
822 if self.use_transparency and self.alpha_mode != "NONE" and self.blend_method == "OPAQUE":
823 box.label(text="'Opaque' does not support alpha", icon="ERROR")
824 if self.blend_method == 'BLEND':
825 row = box.row()
826 row.prop(self, "show_transparent_back")
828 box.label(text="Shadow Mode")
829 row = box.row()
830 row.prop(self, 'shadow_method', expand=True)
832 row = box.row()
833 row.prop(self, "use_backface_culling")
835 engine = context.scene.render.engine
836 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
837 box.label(text=tip_("%s is not supported") % engine, icon='ERROR')
839 box.prop(self, "overwrite_material")
840 layout = self.layout
841 box = layout.box()
842 box.label(text="Texture Settings:", icon='TEXTURE')
843 box.label(text="Interpolation")
844 row = box.row()
845 row.prop(self, 'interpolation', expand=True)
846 box.label(text="Extension")
847 row = box.row()
848 row.prop(self, 'extension', expand=True)
849 row = box.row()
850 row.prop(self, "use_transparency")
851 if self.use_transparency:
852 sub = row.row()
853 sub.prop(self, "alpha_mode", text="")
854 row = box.row()
855 row.prop(self, "use_auto_refresh")
857 def draw_spatial_config(self, context):
858 # --- Spatial Properties: Position, Size and Orientation --- #
859 layout = self.layout
860 box = layout.box()
862 box.label(text="Position:", icon='SNAP_GRID')
863 box.prop(self, "offset")
864 col = box.column()
865 row = col.row()
866 row.prop(self, "offset_axis", expand=True)
867 row = col.row()
868 row.prop(self, "offset_amount")
869 col.enabled = self.offset
871 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
872 row = box.row()
873 row.prop(self, "size_mode", expand=True)
874 if self.size_mode == 'ABSOLUTE':
875 box.prop(self, "height")
876 elif self.size_mode == 'CAMERA':
877 row = box.row()
878 row.prop(self, "fill_mode", expand=True)
879 else:
880 box.prop(self, "factor")
882 box.label(text="Orientation:")
883 row = box.row()
884 row.enabled = 'CAM' not in self.size_mode
885 row.prop(self, "align_axis")
886 row = box.row()
887 row.enabled = 'CAM' in self.align_axis
888 row.alignment = 'RIGHT'
889 row.prop(self, "align_track")
891 def draw(self, context):
893 # Draw configuration sections
894 self.draw_import_config(context)
895 self.draw_material_config(context)
896 self.draw_spatial_config(context)
898 # -------------------------------------------------------------------------
899 # Core functionality
900 def invoke(self, context, event):
901 engine = context.scene.render.engine
902 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
903 if engine != 'BLENDER_WORKBENCH':
904 self.report({'ERROR'}, tip_("Cannot generate materials for unknown %s render engine") % engine)
905 return {'CANCELLED'}
906 else:
907 self.report({'WARNING'},
908 tip_("Generating Cycles/EEVEE compatible material, but won't be visible with %s engine") % engine)
910 # Open file browser
911 context.window_manager.fileselect_add(self)
912 return {'RUNNING_MODAL'}
914 def execute(self, context):
915 if not bpy.data.is_saved:
916 self.relative = False
918 # this won't work in edit mode
919 editmode = context.preferences.edit.use_enter_edit_mode
920 context.preferences.edit.use_enter_edit_mode = False
921 if context.active_object and context.active_object.mode != 'OBJECT':
922 bpy.ops.object.mode_set(mode='OBJECT')
924 self.import_images(context)
926 context.preferences.edit.use_enter_edit_mode = editmode
928 return {'FINISHED'}
930 def import_images(self, context):
932 # load images / sequences
933 images = tuple(load_images(
934 (fn.name for fn in self.files),
935 self.directory,
936 force_reload=self.force_reload,
937 find_sequences=self.image_sequence
940 # Create individual planes
941 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
943 context.view_layer.update()
945 # Align planes relative to each other
946 if self.offset:
947 offset_axis = self.axis_id_to_vector[self.offset_axis]
948 offset_planes(planes, self.offset_amount, offset_axis)
950 if self.size_mode == 'CAMERA' and offset_axis.z:
951 for plane in planes:
952 x, y = compute_camera_size(
953 context, plane.location,
954 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
955 plane.dimensions = x, y, 0.0
957 # setup new selection
958 for plane in planes:
959 plane.select_set(True)
961 # all done!
962 self.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes)))
964 # operate on a single image
965 def single_image_spec_to_plane(self, context, img_spec):
967 # Configure image
968 self.apply_image_options(img_spec.image)
970 # Configure material
971 engine = context.scene.render.engine
972 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
973 material = self.create_cycles_material(context, img_spec)
975 # Create and position plane object
976 plane = self.create_image_plane(context, material.name, img_spec)
978 # Assign Material
979 plane.data.materials.append(material)
981 # If applicable, setup Corner Pin node
982 if self.compositing_nodes:
983 setup_compositing(context, plane, img_spec)
985 return plane
987 def apply_image_options(self, image):
988 if self.use_transparency == False:
989 image.alpha_mode = 'NONE'
990 else:
991 image.alpha_mode = self.alpha_mode
993 if self.relative:
994 try: # can't always find the relative path (between drive letters on windows)
995 image.filepath = bpy.path.relpath(image.filepath)
996 except ValueError:
997 pass
999 def apply_texture_options(self, texture, img_spec):
1000 # Shared by both Cycles and Blender Internal
1001 image_user = texture.image_user
1002 image_user.use_auto_refresh = self.use_auto_refresh
1003 image_user.frame_start = img_spec.frame_start
1004 image_user.frame_offset = img_spec.frame_offset
1005 image_user.frame_duration = img_spec.frame_duration
1007 # Image sequences need auto refresh to display reliably
1008 if img_spec.image.source == 'SEQUENCE':
1009 image_user.use_auto_refresh = True
1011 def apply_material_options(self, material, slot):
1012 shader = self.shader
1014 if self.use_transparency:
1015 material.alpha = 0.0
1016 material.specular_alpha = 0.0
1017 slot.use_map_alpha = True
1018 else:
1019 material.alpha = 1.0
1020 material.specular_alpha = 1.0
1021 slot.use_map_alpha = False
1023 material.specular_intensity = 0
1024 material.diffuse_intensity = 1.0
1025 material.use_transparency = self.use_transparency
1026 material.transparency_method = 'Z_TRANSPARENCY'
1027 material.use_shadeless = (shader == 'SHADELESS')
1028 material.use_transparent_shadows = (shader == 'DIFFUSE')
1029 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
1031 # -------------------------------------------------------------------------
1032 # Cycles/Eevee
1033 def create_cycles_texnode(self, context, node_tree, img_spec):
1034 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
1035 tex_image.image = img_spec.image
1036 tex_image.show_texture = True
1037 tex_image.interpolation = self.interpolation
1038 tex_image.extension = self.extension
1039 self.apply_texture_options(tex_image, img_spec)
1040 return tex_image
1042 def create_cycles_material(self, context, img_spec):
1043 image = img_spec.image
1044 name_compat = bpy.path.display_name_from_filepath(image.filepath)
1045 material = None
1046 if self.overwrite_material:
1047 for mat in bpy.data.materials:
1048 if mat.name == name_compat:
1049 material = mat
1050 if not material:
1051 material = bpy.data.materials.new(name=name_compat)
1053 material.use_nodes = True
1055 material.blend_method = self.blend_method
1056 material.shadow_method = self.shadow_method
1058 material.use_backface_culling = self.use_backface_culling
1059 material.show_transparent_back = self.show_transparent_back
1061 node_tree = material.node_tree
1062 out_node = clean_node_tree(node_tree)
1064 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1066 if self.shader == 'PRINCIPLED':
1067 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1068 elif self.shader == 'SHADELESS':
1069 core_shader = get_shadeless_node(node_tree)
1070 elif self.shader == 'EMISSION':
1071 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1072 core_shader.inputs['Emission Strength'].default_value = self.emit_strength
1073 core_shader.inputs['Base Color'].default_value = (0.0, 0.0, 0.0, 1.0)
1074 core_shader.inputs['Specular'].default_value = 0.0
1076 # Connect color from texture
1077 if self.shader in {'PRINCIPLED', 'SHADELESS'}:
1078 node_tree.links.new(core_shader.inputs[0], tex_image.outputs['Color'])
1079 elif self.shader == 'EMISSION':
1080 node_tree.links.new(core_shader.inputs['Emission'], tex_image.outputs['Color'])
1082 if self.use_transparency:
1083 if self.shader in {'PRINCIPLED', 'EMISSION'}:
1084 node_tree.links.new(core_shader.inputs['Alpha'], tex_image.outputs['Alpha'])
1085 else:
1086 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1088 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1089 node_tree.links.new(mix_shader.inputs['Fac'], tex_image.outputs['Alpha'])
1090 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs['BSDF'])
1091 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1092 core_shader = mix_shader
1094 node_tree.links.new(out_node.inputs['Surface'], core_shader.outputs[0])
1096 auto_align_nodes(node_tree)
1097 return material
1099 # -------------------------------------------------------------------------
1100 # Geometry Creation
1101 def create_image_plane(self, context, name, img_spec):
1103 width, height = self.compute_plane_size(context, img_spec)
1105 # Create new mesh
1106 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1107 plane = context.active_object
1108 # Why does mesh.primitive_plane_add leave the object in edit mode???
1109 if plane.mode != 'OBJECT':
1110 bpy.ops.object.mode_set(mode='OBJECT')
1111 plane.dimensions = width, height, 0.0
1112 plane.data.name = plane.name = name
1113 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1115 # If sizing for camera, also insert into the camera's field of view
1116 if self.size_mode == 'CAMERA':
1117 offset_axis = self.axis_id_to_vector[self.offset_axis]
1118 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1119 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1121 self.align_plane(context, plane)
1123 return plane
1125 def compute_plane_size(self, context, img_spec):
1126 """Given the image size in pixels and location, determine size of plane"""
1127 px, py = img_spec.size
1129 # can't load data
1130 if px == 0 or py == 0:
1131 px = py = 1
1133 if self.size_mode == 'ABSOLUTE':
1134 y = self.height
1135 x = px / py * y
1137 elif self.size_mode == 'CAMERA':
1138 x, y = compute_camera_size(
1139 context, context.scene.cursor.location,
1140 self.fill_mode, px / py
1143 elif self.size_mode == 'DPI':
1144 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1145 x = px * fact
1146 y = py * fact
1148 else: # elif self.size_mode == 'DPBU'
1149 fact = 1 / self.factor
1150 x = px * fact
1151 y = py * fact
1153 return x, y
1155 def align_plane(self, context, plane):
1156 """Pick an axis and align the plane to it"""
1157 if 'CAM' in self.align_axis:
1158 # Camera-aligned
1159 camera = context.scene.camera
1160 if (camera):
1161 # Find the axis that best corresponds to the camera's view direction
1162 axis = camera.matrix_world @ \
1163 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1164 # pick the axis with the greatest magnitude
1165 mag = max(map(abs, axis))
1166 # And use that axis & direction
1167 axis = Vector([
1168 n / mag if abs(n) == mag else 0.0
1169 for n in axis
1171 else:
1172 # No camera? Just face Z axis
1173 axis = Vector((0, 0, 1))
1174 self.align_axis = 'Z+'
1175 else:
1176 # Axis-aligned
1177 axis = self.axis_id_to_vector[self.align_axis]
1179 # rotate accordingly for x/y axiis
1180 if not axis.z:
1181 plane.rotation_euler.x = pi / 2
1183 if axis.y > 0:
1184 plane.rotation_euler.z = pi
1185 elif axis.y < 0:
1186 plane.rotation_euler.z = 0
1187 elif axis.x > 0:
1188 plane.rotation_euler.z = pi / 2
1189 elif axis.x < 0:
1190 plane.rotation_euler.z = -pi / 2
1192 # or flip 180 degrees for negative z
1193 elif axis.z < 0:
1194 plane.rotation_euler.y = pi
1196 if self.align_axis == 'CAM':
1197 constraint = plane.constraints.new('COPY_ROTATION')
1198 constraint.target = camera
1199 constraint.use_x = constraint.use_y = constraint.use_z = True
1200 if not self.align_track:
1201 bpy.ops.object.visual_transform_apply()
1202 plane.constraints.clear()
1204 if self.align_axis == 'CAM_AX' and self.align_track:
1205 constraint = plane.constraints.new('LOCKED_TRACK')
1206 constraint.target = camera
1207 constraint.track_axis = 'TRACK_Z'
1208 constraint.lock_axis = 'LOCK_Y'
1211 # -----------------------------------------------------------------------------
1212 # Register
1214 def import_images_button(self, context):
1215 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1218 classes = (
1219 IMPORT_IMAGE_OT_to_plane,
1223 def register():
1224 for cls in classes:
1225 bpy.utils.register_class(cls)
1227 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1228 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1230 bpy.app.handlers.load_post.append(register_driver)
1231 register_driver()
1234 def unregister():
1235 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1236 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1238 # This will only exist if drivers are active
1239 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1240 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1242 bpy.app.handlers.load_post.remove(register_driver)
1243 del bpy.app.driver_namespace['import_image__find_plane_corner']
1245 for cls in classes:
1246 bpy.utils.unregister_class(cls)
1249 if __name__ == "__main__":
1250 # Run simple doc tests
1251 import doctest
1252 doctest.testmod()
1254 unregister()
1255 register()