Fix T77568: turnaround camera crashes undoing
[blender-addons.git] / io_import_images_as_planes.py
blob5783d70e83f76fcaf92fcfdfd0e1729f1e27af2d
1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
19 # <pep8 compliant>
21 bl_info = {
22 "name": "Import Images as Planes",
23 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc)",
24 "version": (3, 3, 1),
25 "blender": (2, 80, 0),
26 "location": "File > Import > Images as Planes or Add > Mesh > Images as Planes",
27 "description": "Imports images and creates planes with the appropriate aspect ratio. "
28 "The images are mapped to the planes.",
29 "warning": "",
30 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/images_as_planes.html",
31 "support": 'OFFICIAL',
32 "category": "Import-Export",
35 import os
36 import warnings
37 import re
38 from itertools import count, repeat
39 from collections import namedtuple
40 from math import pi
42 import bpy
43 from bpy.types import Operator
44 from mathutils import Vector
46 from bpy.props import (
47 StringProperty,
48 BoolProperty,
49 EnumProperty,
50 FloatProperty,
51 CollectionProperty,
54 from bpy_extras.object_utils import (
55 AddObjectHelper,
56 world_to_camera_view,
59 from bpy_extras.image_utils import load_image
61 # -----------------------------------------------------------------------------
62 # Module-level Shared State
64 watched_objects = {} # used to trigger compositor updates on scene updates
67 # -----------------------------------------------------------------------------
68 # Misc utils.
70 def add_driver_prop(driver, name, type, id, path):
71 """Configure a new driver variable."""
72 dv = driver.variables.new()
73 dv.name = name
74 dv.type = 'SINGLE_PROP'
75 target = dv.targets[0]
76 target.id_type = type
77 target.id = id
78 target.data_path = path
81 # -----------------------------------------------------------------------------
82 # Image loading
84 ImageSpec = namedtuple(
85 'ImageSpec',
86 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
88 num_regex = re.compile('[0-9]') # Find a single number
89 nums_regex = re.compile('[0-9]+') # Find a set of numbers
92 def find_image_sequences(files):
93 """From a group of files, detect image sequences.
95 This returns a generator of tuples, which contain the filename,
96 start frame, and length of the detected sequence
98 >>> list(find_image_sequences([
99 ... "test2-001.jp2", "test2-002.jp2",
100 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
101 ... "blaah"]))
102 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
105 files = iter(sorted(files))
106 prev_file = None
107 pattern = ""
108 matches = []
109 segment = None
110 length = 1
111 for filename in files:
112 new_pattern = num_regex.sub('#', filename)
113 new_matches = list(map(int, nums_regex.findall(filename)))
114 if new_pattern == pattern:
115 # this file looks like it may be in sequence from the previous
117 # if there are multiple sets of numbers, figure out what changed
118 if segment is None:
119 for i, prev, cur in zip(count(), matches, new_matches):
120 if prev != cur:
121 segment = i
122 break
124 # did it only change by one?
125 for i, prev, cur in zip(count(), matches, new_matches):
126 if i == segment:
127 # We expect this to increment
128 prev = prev + length
129 if prev != cur:
130 break
132 # All good!
133 else:
134 length += 1
135 continue
137 # No continuation -> spit out what we found and reset counters
138 if prev_file:
139 if length > 1:
140 yield prev_file, matches[segment], length
141 else:
142 yield prev_file, 1, 1
144 prev_file = filename
145 matches = new_matches
146 pattern = new_pattern
147 segment = None
148 length = 1
150 if prev_file:
151 if length > 1:
152 yield prev_file, matches[segment], length
153 else:
154 yield prev_file, 1, 1
157 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
158 """Wrapper for bpy's load_image
160 Loads a set of images, movies, or even image sequences
161 Returns a generator of ImageSpec wrapper objects later used for texture setup
163 if find_sequences: # if finding sequences, we need some pre-processing first
164 file_iter = find_image_sequences(filenames)
165 else:
166 file_iter = zip(filenames, repeat(1), repeat(1))
168 for filename, offset, frames in file_iter:
169 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
171 # Size is unavailable for sequences, so we grab it early
172 size = tuple(image.size)
174 if image.source == 'MOVIE':
175 # Blender BPY BUG!
176 # This number is only valid when read a second time in 2.77
177 # This repeated line is not a mistake
178 frames = image.frame_duration
179 frames = image.frame_duration
181 elif frames > 1: # Not movie, but multiple frames -> image sequence
182 image.source = 'SEQUENCE'
184 yield ImageSpec(image, size, frame_start, offset - 1, frames)
187 # -----------------------------------------------------------------------------
188 # Position & Size Helpers
190 def offset_planes(planes, gap, axis):
191 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
193 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
194 obj2 0.5 blender units away from obj1 along the local positive Z axis.
196 This is in local space, not world space, so all planes should share
197 a common scale and rotation.
199 prior = planes[0]
200 offset = Vector()
201 for current in planes[1:]:
202 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
204 offset += local_offset * axis
205 current.location = current.matrix_world @ offset
207 prior = current
210 def compute_camera_size(context, center, fill_mode, aspect):
211 """Determine how large an object needs to be to fit or fill the camera's field of view."""
212 scene = context.scene
213 camera = scene.camera
214 view_frame = camera.data.view_frame(scene=scene)
215 frame_size = \
216 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
217 Vector([min(v[i] for v in view_frame) for i in range(3)])
218 camera_aspect = frame_size.x / frame_size.y
220 # Convert the frame size to the correct sizing at a given distance
221 if camera.type == 'ORTHO':
222 frame_size = frame_size.xy
223 else:
224 # Perspective transform
225 distance = world_to_camera_view(scene, camera, center).z
226 frame_size = distance * frame_size.xy / (-view_frame[0].z)
228 # Determine what axis to match to the camera
229 match_axis = 0 # match the Y axis size
230 match_aspect = aspect
231 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
232 (fill_mode == 'FIT' and aspect < camera_aspect):
233 match_axis = 1 # match the X axis size
234 match_aspect = 1.0 / aspect
236 # scale the other axis to the correct aspect
237 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
239 return frame_size
242 def center_in_camera(scene, camera, obj, axis=(1, 1)):
243 """Center object along specified axis of the camera"""
244 camera_matrix_col = camera.matrix_world.col
245 location = obj.location
247 # Vector from the camera's world coordinate center to the object's center
248 delta = camera_matrix_col[3].xyz - location
250 # How far off center we are along the camera's local X
251 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
252 # How far off center we are along the camera's local Y
253 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
255 # Now offset only along camera local axis
256 offset = camera_matrix_col[0].xyz * camera_x_mag + \
257 camera_matrix_col[1].xyz * camera_y_mag
259 obj.location = location + offset
262 # -----------------------------------------------------------------------------
263 # Cycles/Eevee utils
265 def get_input_nodes(node, links):
266 """Get nodes that are a inputs to the given node"""
267 # Get all links going to node.
268 input_links = {lnk for lnk in links if lnk.to_node == node}
269 # Sort those links, get their input nodes (and avoid doubles!).
270 sorted_nodes = []
271 done_nodes = set()
272 for socket in node.inputs:
273 done_links = set()
274 for link in input_links:
275 nd = link.from_node
276 if nd in done_nodes:
277 # Node already treated!
278 done_links.add(link)
279 elif link.to_socket == socket:
280 sorted_nodes.append(nd)
281 done_links.add(link)
282 done_nodes.add(nd)
283 input_links -= done_links
284 return sorted_nodes
287 def auto_align_nodes(node_tree):
288 """Given a shader node tree, arrange nodes neatly relative to the output node."""
289 x_gap = 200
290 y_gap = 180
291 nodes = node_tree.nodes
292 links = node_tree.links
293 output_node = None
294 for node in nodes:
295 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
296 output_node = node
297 break
299 else: # Just in case there is no output
300 return
302 def align(to_node):
303 from_nodes = get_input_nodes(to_node, links)
304 for i, node in enumerate(from_nodes):
305 node.location.x = min(node.location.x, to_node.location.x - x_gap)
306 node.location.y = to_node.location.y
307 node.location.y -= i * y_gap
308 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
309 align(node)
311 align(output_node)
314 def clean_node_tree(node_tree):
315 """Clear all nodes in a shader node tree except the output.
317 Returns the output node
319 nodes = node_tree.nodes
320 for node in list(nodes): # copy to avoid altering the loop's data source
321 if not node.type == 'OUTPUT_MATERIAL':
322 nodes.remove(node)
324 return node_tree.nodes[0]
327 def get_shadeless_node(dest_node_tree):
328 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
329 try:
330 node_tree = bpy.data.node_groups['IAP_SHADELESS']
332 except KeyError:
333 # need to build node shadeless node group
334 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
335 output_node = node_tree.nodes.new('NodeGroupOutput')
336 input_node = node_tree.nodes.new('NodeGroupInput')
338 node_tree.outputs.new('NodeSocketShader', 'Shader')
339 node_tree.inputs.new('NodeSocketColor', 'Color')
341 # This could be faster as a transparent shader, but then no ambient occlusion
342 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
343 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
345 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
346 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
348 light_path = node_tree.nodes.new('ShaderNodeLightPath')
349 is_glossy_ray = light_path.outputs['Is Glossy Ray']
350 is_shadow_ray = light_path.outputs['Is Shadow Ray']
351 ray_depth = light_path.outputs['Ray Depth']
352 transmission_depth = light_path.outputs['Transmission Depth']
354 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
355 unrefracted_depth.operation = 'SUBTRACT'
356 unrefracted_depth.label = 'Bounce Count'
357 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
358 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
360 refracted = node_tree.nodes.new('ShaderNodeMath')
361 refracted.operation = 'SUBTRACT'
362 refracted.label = 'Camera or Refracted'
363 refracted.inputs[0].default_value = 1.0
364 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
366 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
367 reflection_limit.operation = 'SUBTRACT'
368 reflection_limit.label = 'Limit Reflections'
369 reflection_limit.inputs[0].default_value = 2.0
370 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
372 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
373 camera_reflected.operation = 'MULTIPLY'
374 camera_reflected.label = 'Camera Ray to Glossy'
375 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
376 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
378 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
379 shadow_or_reflect.operation = 'MAXIMUM'
380 shadow_or_reflect.label = 'Shadow or Reflection?'
381 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
382 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
384 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
385 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
386 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
387 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
388 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
390 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
391 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
392 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
393 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
395 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
397 auto_align_nodes(node_tree)
399 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
400 group_node.node_tree = node_tree
402 return group_node
405 # -----------------------------------------------------------------------------
406 # Corner Pin Driver Helpers
408 @bpy.app.handlers.persistent
409 def check_drivers(*args, **kwargs):
410 """Check if watched objects in a scene have changed and trigger compositor update
412 This is part of a hack to ensure the compositor updates
413 itself when the objects used for drivers change.
415 It only triggers if transformation matricies change to avoid
416 a cyclic loop of updates.
418 if not watched_objects:
419 # if there is nothing to watch, don't bother running this
420 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
421 return
423 update = False
424 for name, matrix in list(watched_objects.items()):
425 try:
426 obj = bpy.data.objects[name]
427 except KeyError:
428 # The user must have removed this object
429 del watched_objects[name]
430 else:
431 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
432 if new_matrix != matrix:
433 watched_objects[name] = new_matrix
434 update = True
436 if update:
437 # Trick to re-evaluate drivers
438 bpy.context.scene.frame_current = bpy.context.scene.frame_current
441 def register_watched_object(obj):
442 """Register an object to be monitored for transformation changes"""
443 name = obj.name
445 # known object? -> we're done
446 if name in watched_objects:
447 return
449 if not watched_objects:
450 # make sure check_drivers is active
451 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
453 watched_objects[name] = None
456 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
457 """Find the location in camera space of a plane's corner"""
458 if args or kwargs:
459 # I've added args / kwargs as a compatibility measure with future versions
460 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
462 plane = bpy.data.objects[object_name]
464 # Passing in camera doesn't work before 2.78, so we use the current one
465 camera = camera or bpy.context.scene.camera
467 # Hack to ensure compositor updates on future changes
468 register_watched_object(camera)
469 register_watched_object(plane)
471 scale = plane.scale * 2.0
472 v = plane.dimensions.copy()
473 v.x *= x / scale.x
474 v.y *= y / scale.y
475 v = plane.matrix_world @ v
477 camera_vertex = world_to_camera_view(
478 bpy.context.scene, camera, v)
480 return camera_vertex[axis]
483 @bpy.app.handlers.persistent
484 def register_driver(*args, **kwargs):
485 """Register the find_plane_corner function for use with drivers"""
486 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
489 # -----------------------------------------------------------------------------
490 # Compositing Helpers
492 def group_in_frame(node_tree, name, nodes):
493 frame_node = node_tree.nodes.new("NodeFrame")
494 frame_node.label = name
495 frame_node.name = name + "_frame"
497 min_pos = Vector(nodes[0].location)
498 max_pos = min_pos.copy()
500 for node in nodes:
501 top_left = node.location
502 bottom_right = top_left + Vector((node.width, -node.height))
504 for i in (0, 1):
505 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
506 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
508 node.parent = frame_node
510 frame_node.width = max_pos[0] - min_pos[0] + 50
511 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
512 frame_node.shrink = True
514 return frame_node
517 def position_frame_bottom_left(node_tree, frame_node):
518 newpos = Vector((100000, 100000)) # start reasonably far top / right
520 # Align with the furthest left
521 for node in node_tree.nodes.values():
522 if node != frame_node and node.parent != frame_node:
523 newpos.x = min(newpos.x, node.location.x + 30)
525 # As high as we can get without overlapping anything to the right
526 for node in node_tree.nodes.values():
527 if node != frame_node and not node.parent:
528 if node.location.x < newpos.x + frame_node.width:
529 print("Below", node.name, node.location, node.height, node.dimensions)
530 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
532 frame_node.location = newpos
535 def setup_compositing(context, plane, img_spec):
536 # Node Groups only work with "new" dependency graph and even
537 # then it has some problems with not updating the first time
538 # So instead this groups with a node frame, which works reliably
540 scene = context.scene
541 scene.use_nodes = True
542 node_tree = scene.node_tree
543 name = plane.name
545 image_node = node_tree.nodes.new("CompositorNodeImage")
546 image_node.name = name + "_image"
547 image_node.image = img_spec.image
548 image_node.location = Vector((0, 0))
549 image_node.frame_start = img_spec.frame_start
550 image_node.frame_offset = img_spec.frame_offset
551 image_node.frame_duration = img_spec.frame_duration
553 scale_node = node_tree.nodes.new("CompositorNodeScale")
554 scale_node.name = name + "_scale"
555 scale_node.space = 'RENDER_SIZE'
556 scale_node.location = image_node.location + \
557 Vector((image_node.width + 20, 0))
558 scale_node.show_options = False
560 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
561 cornerpin_node.name = name + "_cornerpin"
562 cornerpin_node.location = scale_node.location + \
563 Vector((0, -scale_node.height))
565 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
566 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
568 # Put all the nodes in a frame for organization
569 frame_node = group_in_frame(
570 node_tree, name,
571 (image_node, scale_node, cornerpin_node)
574 # Position frame at bottom / left
575 position_frame_bottom_left(node_tree, frame_node)
577 # Configure Drivers
578 for corner in cornerpin_node.inputs[1:]:
579 id = corner.identifier
580 x = -1 if 'Left' in id else 1
581 y = -1 if 'Lower' in id else 1
582 drivers = corner.driver_add('default_value')
583 for i, axis_fcurve in enumerate(drivers):
584 driver = axis_fcurve.driver
585 # Always use the current camera
586 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
587 # Track camera location to ensure Deps Graph triggers (not used in the call)
588 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
589 # Don't break if the name changes
590 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
591 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
592 repr(plane.name),
593 x, y, i
595 driver.type = 'SCRIPTED'
596 driver.is_valid = True
597 axis_fcurve.is_valid = True
598 driver.expression = "%s" % driver.expression
600 context.view_layer.update()
603 # -----------------------------------------------------------------------------
604 # Operator
606 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
607 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
609 bl_idname = "import_image.to_plane"
610 bl_label = "Import Images as Planes"
611 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
613 # ----------------------
614 # File dialog properties
615 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
617 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
619 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
620 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
621 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
623 # ----------------------
624 # Properties - Importing
625 force_reload: BoolProperty(
626 name="Force Reload", default=False,
627 description="Force reloading of the image if already opened elsewhere in Blender"
630 image_sequence: BoolProperty(
631 name="Animate Image Sequences", default=False,
632 description="Import sequentially numbered images as an animated "
633 "image sequence instead of separate planes"
636 # -------------------------------------
637 # Properties - Position and Orientation
638 axis_id_to_vector = {
639 'X+': Vector(( 1, 0, 0)),
640 'Y+': Vector(( 0, 1, 0)),
641 'Z+': Vector(( 0, 0, 1)),
642 'X-': Vector((-1, 0, 0)),
643 'Y-': Vector(( 0, -1, 0)),
644 'Z-': Vector(( 0, 0, -1)),
647 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
649 OFFSET_MODES = (
650 ('X+', "X+", "Side by Side to the Left"),
651 ('Y+', "Y+", "Side by Side, Downward"),
652 ('Z+', "Z+", "Stacked Above"),
653 ('X-', "X-", "Side by Side to the Right"),
654 ('Y-', "Y-", "Side by Side, Upward"),
655 ('Z-', "Z-", "Stacked Below"),
657 offset_axis: EnumProperty(
658 name="Orientation", default='X+', items=OFFSET_MODES,
659 description="How planes are oriented relative to each others' local axis"
662 offset_amount: FloatProperty(
663 name="Offset", soft_min=0, default=0.1, description="Space between planes",
664 subtype='DISTANCE', unit='LENGTH'
667 AXIS_MODES = (
668 ('X+', "X+", "Facing Positive X"),
669 ('Y+', "Y+", "Facing Positive Y"),
670 ('Z+', "Z+ (Up)", "Facing Positive Z"),
671 ('X-', "X-", "Facing Negative X"),
672 ('Y-', "Y-", "Facing Negative Y"),
673 ('Z-', "Z- (Down)", "Facing Negative Z"),
674 ('CAM', "Face Camera", "Facing Camera"),
675 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
677 align_axis: EnumProperty(
678 name="Align", default='CAM_AX', items=AXIS_MODES,
679 description="How to align the planes"
681 # prev_align_axis is used only by update_size_model
682 prev_align_axis: EnumProperty(
683 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
684 align_track: BoolProperty(
685 name="Track Camera", default=False, description="Always face the camera"
688 # -----------------
689 # Properties - Size
690 def update_size_mode(self, context):
691 """If sizing relative to the camera, always face the camera"""
692 if self.size_mode == 'CAMERA':
693 self.prev_align_axis = self.align_axis
694 self.align_axis = 'CAM'
695 else:
696 # if a different alignment was set revert to that when
697 # size mode is changed
698 if self.prev_align_axis != 'NONE':
699 self.align_axis = self.prev_align_axis
700 self._prev_align_axis = 'NONE'
702 SIZE_MODES = (
703 ('ABSOLUTE', "Absolute", "Use absolute size"),
704 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
705 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
706 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
708 size_mode: EnumProperty(
709 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
710 update=update_size_mode,
711 description="How the size of the plane is computed")
713 FILL_MODES = (
714 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
715 ('FIT', "Fit", "Fit entire image within the camera frame"),
717 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
718 description="How large in the camera frame is the plane")
720 height: FloatProperty(name="Height", description="Height of the created plane",
721 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
723 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
724 description="Number of pixels per inch or Blender Unit")
726 # ------------------------------
727 # Properties - Material / Shader
728 SHADERS = (
729 ('PRINCIPLED',"Principled","Principled Shader"),
730 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
731 ('EMISSION', "Emit", "Emission Shader"),
733 shader: EnumProperty(name="Shader", items=SHADERS, default='PRINCIPLED', description="Node shader to use")
735 emit_strength: FloatProperty(
736 name="Strength", min=0.0, default=1.0, soft_max=10.0,
737 step=100, description="Brightness of Emission Texture")
739 overwrite_material: BoolProperty(
740 name="Overwrite Material", default=True,
741 description="Overwrite existing Material (based on material name)")
743 compositing_nodes: BoolProperty(
744 name="Setup Corner Pin", default=False,
745 description="Build Compositor Nodes to reference this image "
746 "without re-rendering")
748 # ------------------
749 # Properties - Image
750 use_transparency: BoolProperty(
751 name="Use Alpha", default=True,
752 description="Use alpha channel for transparency")
754 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
755 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
756 alpha_mode: EnumProperty(
757 name=t.name, items=alpha_mode_items, default=t.default,
758 description=t.description)
760 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
761 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
763 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
765 # -------
766 # Draw UI
767 def draw_import_config(self, context):
768 # --- Import Options --- #
769 layout = self.layout
770 box = layout.box()
772 box.label(text="Import Options:", icon='IMPORT')
773 row = box.row()
774 row.active = bpy.data.is_saved
775 row.prop(self, "relative")
777 box.prop(self, "force_reload")
778 box.prop(self, "image_sequence")
780 def draw_material_config(self, context):
781 # --- Material / Rendering Properties --- #
782 layout = self.layout
783 box = layout.box()
785 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
786 box.prop(self, "compositing_nodes")
788 box.label(text="Material Settings:", icon='MATERIAL')
790 row = box.row()
791 row.prop(self, 'shader', expand=True)
792 if self.shader == 'EMISSION':
793 box.prop(self, "emit_strength")
795 engine = context.scene.render.engine
796 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
797 box.label(text="%s is not supported" % engine, icon='ERROR')
799 box.prop(self, "overwrite_material")
801 box.label(text="Texture Settings:", icon='TEXTURE')
802 row = box.row()
803 row.prop(self, "use_transparency")
804 sub = row.row()
805 sub.active = self.use_transparency
806 sub.prop(self, "alpha_mode", text="")
807 box.prop(self, "use_auto_refresh")
809 def draw_spatial_config(self, context):
810 # --- Spatial Properties: Position, Size and Orientation --- #
811 layout = self.layout
812 box = layout.box()
814 box.label(text="Position:", icon='SNAP_GRID')
815 box.prop(self, "offset")
816 col = box.column()
817 row = col.row()
818 row.prop(self, "offset_axis", expand=True)
819 row = col.row()
820 row.prop(self, "offset_amount")
821 col.enabled = self.offset
823 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
824 row = box.row()
825 row.prop(self, "size_mode", expand=True)
826 if self.size_mode == 'ABSOLUTE':
827 box.prop(self, "height")
828 elif self.size_mode == 'CAMERA':
829 row = box.row()
830 row.prop(self, "fill_mode", expand=True)
831 else:
832 box.prop(self, "factor")
834 box.label(text="Orientation:")
835 row = box.row()
836 row.enabled = 'CAM' not in self.size_mode
837 row.prop(self, "align_axis")
838 row = box.row()
839 row.enabled = 'CAM' in self.align_axis
840 row.alignment = 'RIGHT'
841 row.prop(self, "align_track")
843 def draw(self, context):
845 # Draw configuration sections
846 self.draw_import_config(context)
847 self.draw_material_config(context)
848 self.draw_spatial_config(context)
850 # -------------------------------------------------------------------------
851 # Core functionality
852 def invoke(self, context, event):
853 engine = context.scene.render.engine
854 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
855 if engine != 'BLENDER_WORKBENCH':
856 self.report({'ERROR'}, "Cannot generate materials for unknown %s render engine" % engine)
857 return {'CANCELLED'}
858 else:
859 self.report({'WARNING'},
860 "Generating Cycles/EEVEE compatible material, but won't be visible with %s engine" % engine)
862 # Open file browser
863 context.window_manager.fileselect_add(self)
864 return {'RUNNING_MODAL'}
866 def execute(self, context):
867 if not bpy.data.is_saved:
868 self.relative = False
870 # this won't work in edit mode
871 editmode = context.preferences.edit.use_enter_edit_mode
872 context.preferences.edit.use_enter_edit_mode = False
873 if context.active_object and context.active_object.mode != 'OBJECT':
874 bpy.ops.object.mode_set(mode='OBJECT')
876 self.import_images(context)
878 context.preferences.edit.use_enter_edit_mode = editmode
880 return {'FINISHED'}
882 def import_images(self, context):
884 # load images / sequences
885 images = tuple(load_images(
886 (fn.name for fn in self.files),
887 self.directory,
888 force_reload=self.force_reload,
889 find_sequences=self.image_sequence
892 # Create individual planes
893 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
895 context.view_layer.update()
897 # Align planes relative to each other
898 if self.offset:
899 offset_axis = self.axis_id_to_vector[self.offset_axis]
900 offset_planes(planes, self.offset_amount, offset_axis)
902 if self.size_mode == 'CAMERA' and offset_axis.z:
903 for plane in planes:
904 x, y = compute_camera_size(
905 context, plane.location,
906 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
907 plane.dimensions = x, y, 0.0
909 # setup new selection
910 for plane in planes:
911 plane.select_set(True)
913 # all done!
914 self.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes)))
916 # operate on a single image
917 def single_image_spec_to_plane(self, context, img_spec):
919 # Configure image
920 self.apply_image_options(img_spec.image)
922 # Configure material
923 engine = context.scene.render.engine
924 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
925 material = self.create_cycles_material(context, img_spec)
927 # Create and position plane object
928 plane = self.create_image_plane(context, material.name, img_spec)
930 # Assign Material
931 plane.data.materials.append(material)
933 # If applicable, setup Corner Pin node
934 if self.compositing_nodes:
935 setup_compositing(context, plane, img_spec)
937 return plane
939 def apply_image_options(self, image):
940 if self.use_transparency == False:
941 image.alpha_mode = 'NONE'
942 else:
943 image.alpha_mode = self.alpha_mode
945 if self.relative:
946 try: # can't always find the relative path (between drive letters on windows)
947 image.filepath = bpy.path.relpath(image.filepath)
948 except ValueError:
949 pass
951 def apply_texture_options(self, texture, img_spec):
952 # Shared by both Cycles and Blender Internal
953 image_user = texture.image_user
954 image_user.use_auto_refresh = self.use_auto_refresh
955 image_user.frame_start = img_spec.frame_start
956 image_user.frame_offset = img_spec.frame_offset
957 image_user.frame_duration = img_spec.frame_duration
959 # Image sequences need auto refresh to display reliably
960 if img_spec.image.source == 'SEQUENCE':
961 image_user.use_auto_refresh = True
963 texture.extension = 'CLIP' # Default of "Repeat" can cause artifacts
965 def apply_material_options(self, material, slot):
966 shader = self.shader
968 if self.use_transparency:
969 material.alpha = 0.0
970 material.specular_alpha = 0.0
971 slot.use_map_alpha = True
972 else:
973 material.alpha = 1.0
974 material.specular_alpha = 1.0
975 slot.use_map_alpha = False
977 material.specular_intensity = 0
978 material.diffuse_intensity = 1.0
979 material.use_transparency = self.use_transparency
980 material.transparency_method = 'Z_TRANSPARENCY'
981 material.use_shadeless = (shader == 'SHADELESS')
982 material.use_transparent_shadows = (shader == 'DIFFUSE')
983 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
985 # -------------------------------------------------------------------------
986 # Cycles/Eevee
987 def create_cycles_texnode(self, context, node_tree, img_spec):
988 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
989 tex_image.image = img_spec.image
990 tex_image.show_texture = True
991 self.apply_texture_options(tex_image, img_spec)
992 return tex_image
994 def create_cycles_material(self, context, img_spec):
995 image = img_spec.image
996 name_compat = bpy.path.display_name_from_filepath(image.filepath)
997 material = None
998 if self.overwrite_material:
999 for mat in bpy.data.materials:
1000 if mat.name == name_compat:
1001 material = mat
1002 if not material:
1003 material = bpy.data.materials.new(name=name_compat)
1005 material.use_nodes = True
1006 if self.use_transparency:
1007 material.blend_method = 'BLEND'
1008 node_tree = material.node_tree
1009 out_node = clean_node_tree(node_tree)
1011 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1013 if self.shader == 'PRINCIPLED':
1014 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1015 elif self.shader == 'SHADELESS':
1016 core_shader = get_shadeless_node(node_tree)
1017 else: # Emission Shading
1018 core_shader = node_tree.nodes.new('ShaderNodeEmission')
1019 core_shader.inputs[1].default_value = self.emit_strength
1021 # Connect color from texture
1022 node_tree.links.new(core_shader.inputs[0], tex_image.outputs[0])
1024 if self.use_transparency:
1025 if self.shader == 'PRINCIPLED':
1026 node_tree.links.new(core_shader.inputs[18], tex_image.outputs[1])
1027 else:
1028 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1030 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1031 node_tree.links.new(mix_shader.inputs[0], tex_image.outputs[1])
1032 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs[0])
1033 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1034 core_shader = mix_shader
1036 node_tree.links.new(out_node.inputs[0], core_shader.outputs[0])
1038 auto_align_nodes(node_tree)
1039 return material
1041 # -------------------------------------------------------------------------
1042 # Geometry Creation
1043 def create_image_plane(self, context, name, img_spec):
1045 width, height = self.compute_plane_size(context, img_spec)
1047 # Create new mesh
1048 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1049 plane = context.active_object
1050 # Why does mesh.primitive_plane_add leave the object in edit mode???
1051 if plane.mode != 'OBJECT':
1052 bpy.ops.object.mode_set(mode='OBJECT')
1053 plane.dimensions = width, height, 0.0
1054 plane.data.name = plane.name = name
1055 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1057 # If sizing for camera, also insert into the camera's field of view
1058 if self.size_mode == 'CAMERA':
1059 offset_axis = self.axis_id_to_vector[self.offset_axis]
1060 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1061 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1063 self.align_plane(context, plane)
1065 return plane
1067 def compute_plane_size(self, context, img_spec):
1068 """Given the image size in pixels and location, determine size of plane"""
1069 px, py = img_spec.size
1071 # can't load data
1072 if px == 0 or py == 0:
1073 px = py = 1
1075 if self.size_mode == 'ABSOLUTE':
1076 y = self.height
1077 x = px / py * y
1079 elif self.size_mode == 'CAMERA':
1080 x, y = compute_camera_size(
1081 context, context.scene.cursor.location,
1082 self.fill_mode, px / py
1085 elif self.size_mode == 'DPI':
1086 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1087 x = px * fact
1088 y = py * fact
1090 else: # elif self.size_mode == 'DPBU'
1091 fact = 1 / self.factor
1092 x = px * fact
1093 y = py * fact
1095 return x, y
1097 def align_plane(self, context, plane):
1098 """Pick an axis and align the plane to it"""
1099 if 'CAM' in self.align_axis:
1100 # Camera-aligned
1101 camera = context.scene.camera
1102 if (camera):
1103 # Find the axis that best corresponds to the camera's view direction
1104 axis = camera.matrix_world @ \
1105 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1106 # pick the axis with the greatest magnitude
1107 mag = max(map(abs, axis))
1108 # And use that axis & direction
1109 axis = Vector([
1110 n / mag if abs(n) == mag else 0.0
1111 for n in axis
1113 else:
1114 # No camera? Just face Z axis
1115 axis = Vector((0, 0, 1))
1116 self.align_axis = 'Z+'
1117 else:
1118 # Axis-aligned
1119 axis = self.axis_id_to_vector[self.align_axis]
1121 # rotate accordingly for x/y axiis
1122 if not axis.z:
1123 plane.rotation_euler.x = pi / 2
1125 if axis.y > 0:
1126 plane.rotation_euler.z = pi
1127 elif axis.y < 0:
1128 plane.rotation_euler.z = 0
1129 elif axis.x > 0:
1130 plane.rotation_euler.z = pi / 2
1131 elif axis.x < 0:
1132 plane.rotation_euler.z = -pi / 2
1134 # or flip 180 degrees for negative z
1135 elif axis.z < 0:
1136 plane.rotation_euler.y = pi
1138 if self.align_axis == 'CAM':
1139 constraint = plane.constraints.new('COPY_ROTATION')
1140 constraint.target = camera
1141 constraint.use_x = constraint.use_y = constraint.use_z = True
1142 if not self.align_track:
1143 bpy.ops.object.visual_transform_apply()
1144 plane.constraints.clear()
1146 if self.align_axis == 'CAM_AX' and self.align_track:
1147 constraint = plane.constraints.new('LOCKED_TRACK')
1148 constraint.target = camera
1149 constraint.track_axis = 'TRACK_Z'
1150 constraint.lock_axis = 'LOCK_Y'
1153 # -----------------------------------------------------------------------------
1154 # Register
1156 def import_images_button(self, context):
1157 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1160 classes = (
1161 IMPORT_IMAGE_OT_to_plane,
1165 def register():
1166 for cls in classes:
1167 bpy.utils.register_class(cls)
1169 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1170 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1172 bpy.app.handlers.load_post.append(register_driver)
1173 register_driver()
1176 def unregister():
1177 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1178 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1180 # This will only exist if drivers are active
1181 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1182 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1184 bpy.app.handlers.load_post.remove(register_driver)
1185 del bpy.app.driver_namespace['import_image__find_plane_corner']
1187 for cls in classes:
1188 bpy.utils.unregister_class(cls)
1191 if __name__ == "__main__":
1192 # Run simple doc tests
1193 import doctest
1194 doctest.testmod()
1196 unregister()
1197 register()