GPencil Tools: Fix T90976 Timeline-scrub toggle
[blender-addons.git] / io_import_images_as_planes.py
blobd842566e1901bcd9989fa27a797274cc1aca71f7
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, 4, 0),
25 "blender": (2, 91, 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 elif self.shader == 'EMISSION':
1018 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1019 core_shader.inputs['Emission Strength'].default_value = self.emit_strength
1020 core_shader.inputs['Base Color'].default_value = (0.0, 0.0, 0.0, 1.0)
1021 core_shader.inputs['Specular'].default_value = 0.0
1023 # Connect color from texture
1024 if self.shader in {'PRINCIPLED', 'SHADELESS'}:
1025 node_tree.links.new(core_shader.inputs[0], tex_image.outputs['Color'])
1026 elif self.shader == 'EMISSION':
1027 node_tree.links.new(core_shader.inputs['Emission'], tex_image.outputs['Color'])
1029 if self.use_transparency:
1030 if self.shader in {'PRINCIPLED', 'EMISSION'}:
1031 node_tree.links.new(core_shader.inputs['Alpha'], tex_image.outputs['Alpha'])
1032 else:
1033 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1035 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1036 node_tree.links.new(mix_shader.inputs['Fac'], tex_image.outputs['Alpha'])
1037 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs['BSDF'])
1038 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1039 core_shader = mix_shader
1041 node_tree.links.new(out_node.inputs['Surface'], core_shader.outputs[0])
1043 auto_align_nodes(node_tree)
1044 return material
1046 # -------------------------------------------------------------------------
1047 # Geometry Creation
1048 def create_image_plane(self, context, name, img_spec):
1050 width, height = self.compute_plane_size(context, img_spec)
1052 # Create new mesh
1053 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1054 plane = context.active_object
1055 # Why does mesh.primitive_plane_add leave the object in edit mode???
1056 if plane.mode != 'OBJECT':
1057 bpy.ops.object.mode_set(mode='OBJECT')
1058 plane.dimensions = width, height, 0.0
1059 plane.data.name = plane.name = name
1060 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1062 # If sizing for camera, also insert into the camera's field of view
1063 if self.size_mode == 'CAMERA':
1064 offset_axis = self.axis_id_to_vector[self.offset_axis]
1065 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1066 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1068 self.align_plane(context, plane)
1070 return plane
1072 def compute_plane_size(self, context, img_spec):
1073 """Given the image size in pixels and location, determine size of plane"""
1074 px, py = img_spec.size
1076 # can't load data
1077 if px == 0 or py == 0:
1078 px = py = 1
1080 if self.size_mode == 'ABSOLUTE':
1081 y = self.height
1082 x = px / py * y
1084 elif self.size_mode == 'CAMERA':
1085 x, y = compute_camera_size(
1086 context, context.scene.cursor.location,
1087 self.fill_mode, px / py
1090 elif self.size_mode == 'DPI':
1091 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1092 x = px * fact
1093 y = py * fact
1095 else: # elif self.size_mode == 'DPBU'
1096 fact = 1 / self.factor
1097 x = px * fact
1098 y = py * fact
1100 return x, y
1102 def align_plane(self, context, plane):
1103 """Pick an axis and align the plane to it"""
1104 if 'CAM' in self.align_axis:
1105 # Camera-aligned
1106 camera = context.scene.camera
1107 if (camera):
1108 # Find the axis that best corresponds to the camera's view direction
1109 axis = camera.matrix_world @ \
1110 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1111 # pick the axis with the greatest magnitude
1112 mag = max(map(abs, axis))
1113 # And use that axis & direction
1114 axis = Vector([
1115 n / mag if abs(n) == mag else 0.0
1116 for n in axis
1118 else:
1119 # No camera? Just face Z axis
1120 axis = Vector((0, 0, 1))
1121 self.align_axis = 'Z+'
1122 else:
1123 # Axis-aligned
1124 axis = self.axis_id_to_vector[self.align_axis]
1126 # rotate accordingly for x/y axiis
1127 if not axis.z:
1128 plane.rotation_euler.x = pi / 2
1130 if axis.y > 0:
1131 plane.rotation_euler.z = pi
1132 elif axis.y < 0:
1133 plane.rotation_euler.z = 0
1134 elif axis.x > 0:
1135 plane.rotation_euler.z = pi / 2
1136 elif axis.x < 0:
1137 plane.rotation_euler.z = -pi / 2
1139 # or flip 180 degrees for negative z
1140 elif axis.z < 0:
1141 plane.rotation_euler.y = pi
1143 if self.align_axis == 'CAM':
1144 constraint = plane.constraints.new('COPY_ROTATION')
1145 constraint.target = camera
1146 constraint.use_x = constraint.use_y = constraint.use_z = True
1147 if not self.align_track:
1148 bpy.ops.object.visual_transform_apply()
1149 plane.constraints.clear()
1151 if self.align_axis == 'CAM_AX' and self.align_track:
1152 constraint = plane.constraints.new('LOCKED_TRACK')
1153 constraint.target = camera
1154 constraint.track_axis = 'TRACK_Z'
1155 constraint.lock_axis = 'LOCK_Y'
1158 # -----------------------------------------------------------------------------
1159 # Register
1161 def import_images_button(self, context):
1162 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1165 classes = (
1166 IMPORT_IMAGE_OT_to_plane,
1170 def register():
1171 for cls in classes:
1172 bpy.utils.register_class(cls)
1174 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1175 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1177 bpy.app.handlers.load_post.append(register_driver)
1178 register_driver()
1181 def unregister():
1182 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1183 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1185 # This will only exist if drivers are active
1186 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1187 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1189 bpy.app.handlers.load_post.remove(register_driver)
1190 del bpy.app.driver_namespace['import_image__find_plane_corner']
1192 for cls in classes:
1193 bpy.utils.unregister_class(cls)
1196 if __name__ == "__main__":
1197 # Run simple doc tests
1198 import doctest
1199 doctest.testmod()
1201 unregister()
1202 register()