object_print3d_utils: replace f-strings by str.format() for I18n
[blender-addons.git] / io_import_images_as_planes.py
blob50a562382cd80c744eb017b0ccc6772cd6c3ebb5
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)",
6 "version": (3, 4, 0),
7 "blender": (2, 91, 0),
8 "location": "File > Import > Images as Planes or Add > Mesh > 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 mathutils import Vector
28 from bpy.props import (
29 StringProperty,
30 BoolProperty,
31 EnumProperty,
32 FloatProperty,
33 CollectionProperty,
36 from bpy_extras.object_utils import (
37 AddObjectHelper,
38 world_to_camera_view,
41 from bpy_extras.image_utils import load_image
43 # -----------------------------------------------------------------------------
44 # Module-level Shared State
46 watched_objects = {} # used to trigger compositor updates on scene updates
49 # -----------------------------------------------------------------------------
50 # Misc utils.
52 def add_driver_prop(driver, name, type, id, path):
53 """Configure a new driver variable."""
54 dv = driver.variables.new()
55 dv.name = name
56 dv.type = 'SINGLE_PROP'
57 target = dv.targets[0]
58 target.id_type = type
59 target.id = id
60 target.data_path = path
63 # -----------------------------------------------------------------------------
64 # Image loading
66 ImageSpec = namedtuple(
67 'ImageSpec',
68 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
70 num_regex = re.compile('[0-9]') # Find a single number
71 nums_regex = re.compile('[0-9]+') # Find a set of numbers
74 def find_image_sequences(files):
75 """From a group of files, detect image sequences.
77 This returns a generator of tuples, which contain the filename,
78 start frame, and length of the detected sequence
80 >>> list(find_image_sequences([
81 ... "test2-001.jp2", "test2-002.jp2",
82 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
83 ... "blaah"]))
84 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
86 """
87 files = iter(sorted(files))
88 prev_file = None
89 pattern = ""
90 matches = []
91 segment = None
92 length = 1
93 for filename in files:
94 new_pattern = num_regex.sub('#', filename)
95 new_matches = list(map(int, nums_regex.findall(filename)))
96 if new_pattern == pattern:
97 # this file looks like it may be in sequence from the previous
99 # if there are multiple sets of numbers, figure out what changed
100 if segment is None:
101 for i, prev, cur in zip(count(), matches, new_matches):
102 if prev != cur:
103 segment = i
104 break
106 # did it only change by one?
107 for i, prev, cur in zip(count(), matches, new_matches):
108 if i == segment:
109 # We expect this to increment
110 prev = prev + length
111 if prev != cur:
112 break
114 # All good!
115 else:
116 length += 1
117 continue
119 # No continuation -> spit out what we found and reset counters
120 if prev_file:
121 if length > 1:
122 yield prev_file, matches[segment], length
123 else:
124 yield prev_file, 1, 1
126 prev_file = filename
127 matches = new_matches
128 pattern = new_pattern
129 segment = None
130 length = 1
132 if prev_file:
133 if length > 1:
134 yield prev_file, matches[segment], length
135 else:
136 yield prev_file, 1, 1
139 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
140 """Wrapper for bpy's load_image
142 Loads a set of images, movies, or even image sequences
143 Returns a generator of ImageSpec wrapper objects later used for texture setup
145 if find_sequences: # if finding sequences, we need some pre-processing first
146 file_iter = find_image_sequences(filenames)
147 else:
148 file_iter = zip(filenames, repeat(1), repeat(1))
150 for filename, offset, frames in file_iter:
151 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
153 # Size is unavailable for sequences, so we grab it early
154 size = tuple(image.size)
156 if image.source == 'MOVIE':
157 # Blender BPY BUG!
158 # This number is only valid when read a second time in 2.77
159 # This repeated line is not a mistake
160 frames = image.frame_duration
161 frames = image.frame_duration
163 elif frames > 1: # Not movie, but multiple frames -> image sequence
164 image.source = 'SEQUENCE'
166 yield ImageSpec(image, size, frame_start, offset - 1, frames)
169 # -----------------------------------------------------------------------------
170 # Position & Size Helpers
172 def offset_planes(planes, gap, axis):
173 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
175 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
176 obj2 0.5 blender units away from obj1 along the local positive Z axis.
178 This is in local space, not world space, so all planes should share
179 a common scale and rotation.
181 prior = planes[0]
182 offset = Vector()
183 for current in planes[1:]:
184 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
186 offset += local_offset * axis
187 current.location = current.matrix_world @ offset
189 prior = current
192 def compute_camera_size(context, center, fill_mode, aspect):
193 """Determine how large an object needs to be to fit or fill the camera's field of view."""
194 scene = context.scene
195 camera = scene.camera
196 view_frame = camera.data.view_frame(scene=scene)
197 frame_size = \
198 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
199 Vector([min(v[i] for v in view_frame) for i in range(3)])
200 camera_aspect = frame_size.x / frame_size.y
202 # Convert the frame size to the correct sizing at a given distance
203 if camera.type == 'ORTHO':
204 frame_size = frame_size.xy
205 else:
206 # Perspective transform
207 distance = world_to_camera_view(scene, camera, center).z
208 frame_size = distance * frame_size.xy / (-view_frame[0].z)
210 # Determine what axis to match to the camera
211 match_axis = 0 # match the Y axis size
212 match_aspect = aspect
213 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
214 (fill_mode == 'FIT' and aspect < camera_aspect):
215 match_axis = 1 # match the X axis size
216 match_aspect = 1.0 / aspect
218 # scale the other axis to the correct aspect
219 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
221 return frame_size
224 def center_in_camera(scene, camera, obj, axis=(1, 1)):
225 """Center object along specified axis of the camera"""
226 camera_matrix_col = camera.matrix_world.col
227 location = obj.location
229 # Vector from the camera's world coordinate center to the object's center
230 delta = camera_matrix_col[3].xyz - location
232 # How far off center we are along the camera's local X
233 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
234 # How far off center we are along the camera's local Y
235 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
237 # Now offset only along camera local axis
238 offset = camera_matrix_col[0].xyz * camera_x_mag + \
239 camera_matrix_col[1].xyz * camera_y_mag
241 obj.location = location + offset
244 # -----------------------------------------------------------------------------
245 # Cycles/Eevee utils
247 def get_input_nodes(node, links):
248 """Get nodes that are a inputs to the given node"""
249 # Get all links going to node.
250 input_links = {lnk for lnk in links if lnk.to_node == node}
251 # Sort those links, get their input nodes (and avoid doubles!).
252 sorted_nodes = []
253 done_nodes = set()
254 for socket in node.inputs:
255 done_links = set()
256 for link in input_links:
257 nd = link.from_node
258 if nd in done_nodes:
259 # Node already treated!
260 done_links.add(link)
261 elif link.to_socket == socket:
262 sorted_nodes.append(nd)
263 done_links.add(link)
264 done_nodes.add(nd)
265 input_links -= done_links
266 return sorted_nodes
269 def auto_align_nodes(node_tree):
270 """Given a shader node tree, arrange nodes neatly relative to the output node."""
271 x_gap = 200
272 y_gap = 180
273 nodes = node_tree.nodes
274 links = node_tree.links
275 output_node = None
276 for node in nodes:
277 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
278 output_node = node
279 break
281 else: # Just in case there is no output
282 return
284 def align(to_node):
285 from_nodes = get_input_nodes(to_node, links)
286 for i, node in enumerate(from_nodes):
287 node.location.x = min(node.location.x, to_node.location.x - x_gap)
288 node.location.y = to_node.location.y
289 node.location.y -= i * y_gap
290 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
291 align(node)
293 align(output_node)
296 def clean_node_tree(node_tree):
297 """Clear all nodes in a shader node tree except the output.
299 Returns the output node
301 nodes = node_tree.nodes
302 for node in list(nodes): # copy to avoid altering the loop's data source
303 if not node.type == 'OUTPUT_MATERIAL':
304 nodes.remove(node)
306 return node_tree.nodes[0]
309 def get_shadeless_node(dest_node_tree):
310 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
311 try:
312 node_tree = bpy.data.node_groups['IAP_SHADELESS']
314 except KeyError:
315 # need to build node shadeless node group
316 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
317 output_node = node_tree.nodes.new('NodeGroupOutput')
318 input_node = node_tree.nodes.new('NodeGroupInput')
320 node_tree.outputs.new('NodeSocketShader', 'Shader')
321 node_tree.inputs.new('NodeSocketColor', 'Color')
323 # This could be faster as a transparent shader, but then no ambient occlusion
324 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
325 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
327 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
328 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
330 light_path = node_tree.nodes.new('ShaderNodeLightPath')
331 is_glossy_ray = light_path.outputs['Is Glossy Ray']
332 is_shadow_ray = light_path.outputs['Is Shadow Ray']
333 ray_depth = light_path.outputs['Ray Depth']
334 transmission_depth = light_path.outputs['Transmission Depth']
336 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
337 unrefracted_depth.operation = 'SUBTRACT'
338 unrefracted_depth.label = 'Bounce Count'
339 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
340 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
342 refracted = node_tree.nodes.new('ShaderNodeMath')
343 refracted.operation = 'SUBTRACT'
344 refracted.label = 'Camera or Refracted'
345 refracted.inputs[0].default_value = 1.0
346 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
348 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
349 reflection_limit.operation = 'SUBTRACT'
350 reflection_limit.label = 'Limit Reflections'
351 reflection_limit.inputs[0].default_value = 2.0
352 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
354 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
355 camera_reflected.operation = 'MULTIPLY'
356 camera_reflected.label = 'Camera Ray to Glossy'
357 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
358 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
360 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
361 shadow_or_reflect.operation = 'MAXIMUM'
362 shadow_or_reflect.label = 'Shadow or Reflection?'
363 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
364 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
366 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
367 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
368 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
369 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
370 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
372 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
373 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
374 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
375 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
377 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
379 auto_align_nodes(node_tree)
381 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
382 group_node.node_tree = node_tree
384 return group_node
387 # -----------------------------------------------------------------------------
388 # Corner Pin Driver Helpers
390 @bpy.app.handlers.persistent
391 def check_drivers(*args, **kwargs):
392 """Check if watched objects in a scene have changed and trigger compositor update
394 This is part of a hack to ensure the compositor updates
395 itself when the objects used for drivers change.
397 It only triggers if transformation matricies change to avoid
398 a cyclic loop of updates.
400 if not watched_objects:
401 # if there is nothing to watch, don't bother running this
402 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
403 return
405 update = False
406 for name, matrix in list(watched_objects.items()):
407 try:
408 obj = bpy.data.objects[name]
409 except KeyError:
410 # The user must have removed this object
411 del watched_objects[name]
412 else:
413 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
414 if new_matrix != matrix:
415 watched_objects[name] = new_matrix
416 update = True
418 if update:
419 # Trick to re-evaluate drivers
420 bpy.context.scene.frame_current = bpy.context.scene.frame_current
423 def register_watched_object(obj):
424 """Register an object to be monitored for transformation changes"""
425 name = obj.name
427 # known object? -> we're done
428 if name in watched_objects:
429 return
431 if not watched_objects:
432 # make sure check_drivers is active
433 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
435 watched_objects[name] = None
438 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
439 """Find the location in camera space of a plane's corner"""
440 if args or kwargs:
441 # I've added args / kwargs as a compatibility measure with future versions
442 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
444 plane = bpy.data.objects[object_name]
446 # Passing in camera doesn't work before 2.78, so we use the current one
447 camera = camera or bpy.context.scene.camera
449 # Hack to ensure compositor updates on future changes
450 register_watched_object(camera)
451 register_watched_object(plane)
453 scale = plane.scale * 2.0
454 v = plane.dimensions.copy()
455 v.x *= x / scale.x
456 v.y *= y / scale.y
457 v = plane.matrix_world @ v
459 camera_vertex = world_to_camera_view(
460 bpy.context.scene, camera, v)
462 return camera_vertex[axis]
465 @bpy.app.handlers.persistent
466 def register_driver(*args, **kwargs):
467 """Register the find_plane_corner function for use with drivers"""
468 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
471 # -----------------------------------------------------------------------------
472 # Compositing Helpers
474 def group_in_frame(node_tree, name, nodes):
475 frame_node = node_tree.nodes.new("NodeFrame")
476 frame_node.label = name
477 frame_node.name = name + "_frame"
479 min_pos = Vector(nodes[0].location)
480 max_pos = min_pos.copy()
482 for node in nodes:
483 top_left = node.location
484 bottom_right = top_left + Vector((node.width, -node.height))
486 for i in (0, 1):
487 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
488 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
490 node.parent = frame_node
492 frame_node.width = max_pos[0] - min_pos[0] + 50
493 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
494 frame_node.shrink = True
496 return frame_node
499 def position_frame_bottom_left(node_tree, frame_node):
500 newpos = Vector((100000, 100000)) # start reasonably far top / right
502 # Align with the furthest left
503 for node in node_tree.nodes.values():
504 if node != frame_node and node.parent != frame_node:
505 newpos.x = min(newpos.x, node.location.x + 30)
507 # As high as we can get without overlapping anything to the right
508 for node in node_tree.nodes.values():
509 if node != frame_node and not node.parent:
510 if node.location.x < newpos.x + frame_node.width:
511 print("Below", node.name, node.location, node.height, node.dimensions)
512 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
514 frame_node.location = newpos
517 def setup_compositing(context, plane, img_spec):
518 # Node Groups only work with "new" dependency graph and even
519 # then it has some problems with not updating the first time
520 # So instead this groups with a node frame, which works reliably
522 scene = context.scene
523 scene.use_nodes = True
524 node_tree = scene.node_tree
525 name = plane.name
527 image_node = node_tree.nodes.new("CompositorNodeImage")
528 image_node.name = name + "_image"
529 image_node.image = img_spec.image
530 image_node.location = Vector((0, 0))
531 image_node.frame_start = img_spec.frame_start
532 image_node.frame_offset = img_spec.frame_offset
533 image_node.frame_duration = img_spec.frame_duration
535 scale_node = node_tree.nodes.new("CompositorNodeScale")
536 scale_node.name = name + "_scale"
537 scale_node.space = 'RENDER_SIZE'
538 scale_node.location = image_node.location + \
539 Vector((image_node.width + 20, 0))
540 scale_node.show_options = False
542 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
543 cornerpin_node.name = name + "_cornerpin"
544 cornerpin_node.location = scale_node.location + \
545 Vector((0, -scale_node.height))
547 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
548 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
550 # Put all the nodes in a frame for organization
551 frame_node = group_in_frame(
552 node_tree, name,
553 (image_node, scale_node, cornerpin_node)
556 # Position frame at bottom / left
557 position_frame_bottom_left(node_tree, frame_node)
559 # Configure Drivers
560 for corner in cornerpin_node.inputs[1:]:
561 id = corner.identifier
562 x = -1 if 'Left' in id else 1
563 y = -1 if 'Lower' in id else 1
564 drivers = corner.driver_add('default_value')
565 for i, axis_fcurve in enumerate(drivers):
566 driver = axis_fcurve.driver
567 # Always use the current camera
568 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
569 # Track camera location to ensure Deps Graph triggers (not used in the call)
570 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
571 # Don't break if the name changes
572 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
573 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
574 repr(plane.name),
575 x, y, i
577 driver.type = 'SCRIPTED'
578 driver.is_valid = True
579 axis_fcurve.is_valid = True
580 driver.expression = "%s" % driver.expression
582 context.view_layer.update()
585 # -----------------------------------------------------------------------------
586 # Operator
588 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
589 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
591 bl_idname = "import_image.to_plane"
592 bl_label = "Import Images as Planes"
593 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
595 # ----------------------
596 # File dialog properties
597 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
599 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
601 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
602 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
603 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
605 # ----------------------
606 # Properties - Importing
607 force_reload: BoolProperty(
608 name="Force Reload", default=False,
609 description="Force reloading of the image if already opened elsewhere in Blender"
612 image_sequence: BoolProperty(
613 name="Animate Image Sequences", default=False,
614 description="Import sequentially numbered images as an animated "
615 "image sequence instead of separate planes"
618 # -------------------------------------
619 # Properties - Position and Orientation
620 axis_id_to_vector = {
621 'X+': Vector(( 1, 0, 0)),
622 'Y+': Vector(( 0, 1, 0)),
623 'Z+': Vector(( 0, 0, 1)),
624 'X-': Vector((-1, 0, 0)),
625 'Y-': Vector(( 0, -1, 0)),
626 'Z-': Vector(( 0, 0, -1)),
629 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
631 OFFSET_MODES = (
632 ('X+', "X+", "Side by Side to the Left"),
633 ('Y+', "Y+", "Side by Side, Downward"),
634 ('Z+', "Z+", "Stacked Above"),
635 ('X-', "X-", "Side by Side to the Right"),
636 ('Y-', "Y-", "Side by Side, Upward"),
637 ('Z-', "Z-", "Stacked Below"),
639 offset_axis: EnumProperty(
640 name="Orientation", default='X+', items=OFFSET_MODES,
641 description="How planes are oriented relative to each others' local axis"
644 offset_amount: FloatProperty(
645 name="Offset", soft_min=0, default=0.1, description="Space between planes",
646 subtype='DISTANCE', unit='LENGTH'
649 AXIS_MODES = (
650 ('X+', "X+", "Facing Positive X"),
651 ('Y+', "Y+", "Facing Positive Y"),
652 ('Z+', "Z+ (Up)", "Facing Positive Z"),
653 ('X-', "X-", "Facing Negative X"),
654 ('Y-', "Y-", "Facing Negative Y"),
655 ('Z-', "Z- (Down)", "Facing Negative Z"),
656 ('CAM', "Face Camera", "Facing Camera"),
657 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
659 align_axis: EnumProperty(
660 name="Align", default='CAM_AX', items=AXIS_MODES,
661 description="How to align the planes"
663 # prev_align_axis is used only by update_size_model
664 prev_align_axis: EnumProperty(
665 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
666 align_track: BoolProperty(
667 name="Track Camera", default=False, description="Always face the camera"
670 # -----------------
671 # Properties - Size
672 def update_size_mode(self, context):
673 """If sizing relative to the camera, always face the camera"""
674 if self.size_mode == 'CAMERA':
675 self.prev_align_axis = self.align_axis
676 self.align_axis = 'CAM'
677 else:
678 # if a different alignment was set revert to that when
679 # size mode is changed
680 if self.prev_align_axis != 'NONE':
681 self.align_axis = self.prev_align_axis
682 self._prev_align_axis = 'NONE'
684 SIZE_MODES = (
685 ('ABSOLUTE', "Absolute", "Use absolute size"),
686 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
687 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
688 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
690 size_mode: EnumProperty(
691 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
692 update=update_size_mode,
693 description="How the size of the plane is computed")
695 FILL_MODES = (
696 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
697 ('FIT', "Fit", "Fit entire image within the camera frame"),
699 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
700 description="How large in the camera frame is the plane")
702 height: FloatProperty(name="Height", description="Height of the created plane",
703 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
705 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
706 description="Number of pixels per inch or Blender Unit")
708 # ------------------------------
709 # Properties - Material / Shader
710 SHADERS = (
711 ('PRINCIPLED',"Principled","Principled Shader"),
712 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
713 ('EMISSION', "Emit", "Emission Shader"),
715 shader: EnumProperty(name="Shader", items=SHADERS, default='PRINCIPLED', description="Node shader to use")
717 emit_strength: FloatProperty(
718 name="Strength", min=0.0, default=1.0, soft_max=10.0,
719 step=100, description="Brightness of Emission Texture")
721 overwrite_material: BoolProperty(
722 name="Overwrite Material", default=True,
723 description="Overwrite existing Material (based on material name)")
725 compositing_nodes: BoolProperty(
726 name="Setup Corner Pin", default=False,
727 description="Build Compositor Nodes to reference this image "
728 "without re-rendering")
730 # ------------------
731 # Properties - Image
732 use_transparency: BoolProperty(
733 name="Use Alpha", default=True,
734 description="Use alpha channel for transparency")
736 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
737 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
738 alpha_mode: EnumProperty(
739 name=t.name, items=alpha_mode_items, default=t.default,
740 description=t.description)
742 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
743 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
745 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
747 # -------
748 # Draw UI
749 def draw_import_config(self, context):
750 # --- Import Options --- #
751 layout = self.layout
752 box = layout.box()
754 box.label(text="Import Options:", icon='IMPORT')
755 row = box.row()
756 row.active = bpy.data.is_saved
757 row.prop(self, "relative")
759 box.prop(self, "force_reload")
760 box.prop(self, "image_sequence")
762 def draw_material_config(self, context):
763 # --- Material / Rendering Properties --- #
764 layout = self.layout
765 box = layout.box()
767 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
768 box.prop(self, "compositing_nodes")
770 box.label(text="Material Settings:", icon='MATERIAL')
772 row = box.row()
773 row.prop(self, 'shader', expand=True)
774 if self.shader == 'EMISSION':
775 box.prop(self, "emit_strength")
777 engine = context.scene.render.engine
778 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
779 box.label(text="%s is not supported" % engine, icon='ERROR')
781 box.prop(self, "overwrite_material")
783 box.label(text="Texture Settings:", icon='TEXTURE')
784 row = box.row()
785 row.prop(self, "use_transparency")
786 sub = row.row()
787 sub.active = self.use_transparency
788 sub.prop(self, "alpha_mode", text="")
789 box.prop(self, "use_auto_refresh")
791 def draw_spatial_config(self, context):
792 # --- Spatial Properties: Position, Size and Orientation --- #
793 layout = self.layout
794 box = layout.box()
796 box.label(text="Position:", icon='SNAP_GRID')
797 box.prop(self, "offset")
798 col = box.column()
799 row = col.row()
800 row.prop(self, "offset_axis", expand=True)
801 row = col.row()
802 row.prop(self, "offset_amount")
803 col.enabled = self.offset
805 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
806 row = box.row()
807 row.prop(self, "size_mode", expand=True)
808 if self.size_mode == 'ABSOLUTE':
809 box.prop(self, "height")
810 elif self.size_mode == 'CAMERA':
811 row = box.row()
812 row.prop(self, "fill_mode", expand=True)
813 else:
814 box.prop(self, "factor")
816 box.label(text="Orientation:")
817 row = box.row()
818 row.enabled = 'CAM' not in self.size_mode
819 row.prop(self, "align_axis")
820 row = box.row()
821 row.enabled = 'CAM' in self.align_axis
822 row.alignment = 'RIGHT'
823 row.prop(self, "align_track")
825 def draw(self, context):
827 # Draw configuration sections
828 self.draw_import_config(context)
829 self.draw_material_config(context)
830 self.draw_spatial_config(context)
832 # -------------------------------------------------------------------------
833 # Core functionality
834 def invoke(self, context, event):
835 engine = context.scene.render.engine
836 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
837 if engine != 'BLENDER_WORKBENCH':
838 self.report({'ERROR'}, "Cannot generate materials for unknown %s render engine" % engine)
839 return {'CANCELLED'}
840 else:
841 self.report({'WARNING'},
842 "Generating Cycles/EEVEE compatible material, but won't be visible with %s engine" % engine)
844 # Open file browser
845 context.window_manager.fileselect_add(self)
846 return {'RUNNING_MODAL'}
848 def execute(self, context):
849 if not bpy.data.is_saved:
850 self.relative = False
852 # this won't work in edit mode
853 editmode = context.preferences.edit.use_enter_edit_mode
854 context.preferences.edit.use_enter_edit_mode = False
855 if context.active_object and context.active_object.mode != 'OBJECT':
856 bpy.ops.object.mode_set(mode='OBJECT')
858 self.import_images(context)
860 context.preferences.edit.use_enter_edit_mode = editmode
862 return {'FINISHED'}
864 def import_images(self, context):
866 # load images / sequences
867 images = tuple(load_images(
868 (fn.name for fn in self.files),
869 self.directory,
870 force_reload=self.force_reload,
871 find_sequences=self.image_sequence
874 # Create individual planes
875 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
877 context.view_layer.update()
879 # Align planes relative to each other
880 if self.offset:
881 offset_axis = self.axis_id_to_vector[self.offset_axis]
882 offset_planes(planes, self.offset_amount, offset_axis)
884 if self.size_mode == 'CAMERA' and offset_axis.z:
885 for plane in planes:
886 x, y = compute_camera_size(
887 context, plane.location,
888 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
889 plane.dimensions = x, y, 0.0
891 # setup new selection
892 for plane in planes:
893 plane.select_set(True)
895 # all done!
896 self.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes)))
898 # operate on a single image
899 def single_image_spec_to_plane(self, context, img_spec):
901 # Configure image
902 self.apply_image_options(img_spec.image)
904 # Configure material
905 engine = context.scene.render.engine
906 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
907 material = self.create_cycles_material(context, img_spec)
909 # Create and position plane object
910 plane = self.create_image_plane(context, material.name, img_spec)
912 # Assign Material
913 plane.data.materials.append(material)
915 # If applicable, setup Corner Pin node
916 if self.compositing_nodes:
917 setup_compositing(context, plane, img_spec)
919 return plane
921 def apply_image_options(self, image):
922 if self.use_transparency == False:
923 image.alpha_mode = 'NONE'
924 else:
925 image.alpha_mode = self.alpha_mode
927 if self.relative:
928 try: # can't always find the relative path (between drive letters on windows)
929 image.filepath = bpy.path.relpath(image.filepath)
930 except ValueError:
931 pass
933 def apply_texture_options(self, texture, img_spec):
934 # Shared by both Cycles and Blender Internal
935 image_user = texture.image_user
936 image_user.use_auto_refresh = self.use_auto_refresh
937 image_user.frame_start = img_spec.frame_start
938 image_user.frame_offset = img_spec.frame_offset
939 image_user.frame_duration = img_spec.frame_duration
941 # Image sequences need auto refresh to display reliably
942 if img_spec.image.source == 'SEQUENCE':
943 image_user.use_auto_refresh = True
945 texture.extension = 'CLIP' # Default of "Repeat" can cause artifacts
947 def apply_material_options(self, material, slot):
948 shader = self.shader
950 if self.use_transparency:
951 material.alpha = 0.0
952 material.specular_alpha = 0.0
953 slot.use_map_alpha = True
954 else:
955 material.alpha = 1.0
956 material.specular_alpha = 1.0
957 slot.use_map_alpha = False
959 material.specular_intensity = 0
960 material.diffuse_intensity = 1.0
961 material.use_transparency = self.use_transparency
962 material.transparency_method = 'Z_TRANSPARENCY'
963 material.use_shadeless = (shader == 'SHADELESS')
964 material.use_transparent_shadows = (shader == 'DIFFUSE')
965 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
967 # -------------------------------------------------------------------------
968 # Cycles/Eevee
969 def create_cycles_texnode(self, context, node_tree, img_spec):
970 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
971 tex_image.image = img_spec.image
972 tex_image.show_texture = True
973 self.apply_texture_options(tex_image, img_spec)
974 return tex_image
976 def create_cycles_material(self, context, img_spec):
977 image = img_spec.image
978 name_compat = bpy.path.display_name_from_filepath(image.filepath)
979 material = None
980 if self.overwrite_material:
981 for mat in bpy.data.materials:
982 if mat.name == name_compat:
983 material = mat
984 if not material:
985 material = bpy.data.materials.new(name=name_compat)
987 material.use_nodes = True
988 if self.use_transparency:
989 material.blend_method = 'BLEND'
990 node_tree = material.node_tree
991 out_node = clean_node_tree(node_tree)
993 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
995 if self.shader == 'PRINCIPLED':
996 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
997 elif self.shader == 'SHADELESS':
998 core_shader = get_shadeless_node(node_tree)
999 elif self.shader == 'EMISSION':
1000 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1001 core_shader.inputs['Emission Strength'].default_value = self.emit_strength
1002 core_shader.inputs['Base Color'].default_value = (0.0, 0.0, 0.0, 1.0)
1003 core_shader.inputs['Specular'].default_value = 0.0
1005 # Connect color from texture
1006 if self.shader in {'PRINCIPLED', 'SHADELESS'}:
1007 node_tree.links.new(core_shader.inputs[0], tex_image.outputs['Color'])
1008 elif self.shader == 'EMISSION':
1009 node_tree.links.new(core_shader.inputs['Emission'], tex_image.outputs['Color'])
1011 if self.use_transparency:
1012 if self.shader in {'PRINCIPLED', 'EMISSION'}:
1013 node_tree.links.new(core_shader.inputs['Alpha'], tex_image.outputs['Alpha'])
1014 else:
1015 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1017 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1018 node_tree.links.new(mix_shader.inputs['Fac'], tex_image.outputs['Alpha'])
1019 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs['BSDF'])
1020 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1021 core_shader = mix_shader
1023 node_tree.links.new(out_node.inputs['Surface'], core_shader.outputs[0])
1025 auto_align_nodes(node_tree)
1026 return material
1028 # -------------------------------------------------------------------------
1029 # Geometry Creation
1030 def create_image_plane(self, context, name, img_spec):
1032 width, height = self.compute_plane_size(context, img_spec)
1034 # Create new mesh
1035 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1036 plane = context.active_object
1037 # Why does mesh.primitive_plane_add leave the object in edit mode???
1038 if plane.mode != 'OBJECT':
1039 bpy.ops.object.mode_set(mode='OBJECT')
1040 plane.dimensions = width, height, 0.0
1041 plane.data.name = plane.name = name
1042 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1044 # If sizing for camera, also insert into the camera's field of view
1045 if self.size_mode == 'CAMERA':
1046 offset_axis = self.axis_id_to_vector[self.offset_axis]
1047 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1048 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1050 self.align_plane(context, plane)
1052 return plane
1054 def compute_plane_size(self, context, img_spec):
1055 """Given the image size in pixels and location, determine size of plane"""
1056 px, py = img_spec.size
1058 # can't load data
1059 if px == 0 or py == 0:
1060 px = py = 1
1062 if self.size_mode == 'ABSOLUTE':
1063 y = self.height
1064 x = px / py * y
1066 elif self.size_mode == 'CAMERA':
1067 x, y = compute_camera_size(
1068 context, context.scene.cursor.location,
1069 self.fill_mode, px / py
1072 elif self.size_mode == 'DPI':
1073 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1074 x = px * fact
1075 y = py * fact
1077 else: # elif self.size_mode == 'DPBU'
1078 fact = 1 / self.factor
1079 x = px * fact
1080 y = py * fact
1082 return x, y
1084 def align_plane(self, context, plane):
1085 """Pick an axis and align the plane to it"""
1086 if 'CAM' in self.align_axis:
1087 # Camera-aligned
1088 camera = context.scene.camera
1089 if (camera):
1090 # Find the axis that best corresponds to the camera's view direction
1091 axis = camera.matrix_world @ \
1092 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1093 # pick the axis with the greatest magnitude
1094 mag = max(map(abs, axis))
1095 # And use that axis & direction
1096 axis = Vector([
1097 n / mag if abs(n) == mag else 0.0
1098 for n in axis
1100 else:
1101 # No camera? Just face Z axis
1102 axis = Vector((0, 0, 1))
1103 self.align_axis = 'Z+'
1104 else:
1105 # Axis-aligned
1106 axis = self.axis_id_to_vector[self.align_axis]
1108 # rotate accordingly for x/y axiis
1109 if not axis.z:
1110 plane.rotation_euler.x = pi / 2
1112 if axis.y > 0:
1113 plane.rotation_euler.z = pi
1114 elif axis.y < 0:
1115 plane.rotation_euler.z = 0
1116 elif axis.x > 0:
1117 plane.rotation_euler.z = pi / 2
1118 elif axis.x < 0:
1119 plane.rotation_euler.z = -pi / 2
1121 # or flip 180 degrees for negative z
1122 elif axis.z < 0:
1123 plane.rotation_euler.y = pi
1125 if self.align_axis == 'CAM':
1126 constraint = plane.constraints.new('COPY_ROTATION')
1127 constraint.target = camera
1128 constraint.use_x = constraint.use_y = constraint.use_z = True
1129 if not self.align_track:
1130 bpy.ops.object.visual_transform_apply()
1131 plane.constraints.clear()
1133 if self.align_axis == 'CAM_AX' and self.align_track:
1134 constraint = plane.constraints.new('LOCKED_TRACK')
1135 constraint.target = camera
1136 constraint.track_axis = 'TRACK_Z'
1137 constraint.lock_axis = 'LOCK_Y'
1140 # -----------------------------------------------------------------------------
1141 # Register
1143 def import_images_button(self, context):
1144 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1147 classes = (
1148 IMPORT_IMAGE_OT_to_plane,
1152 def register():
1153 for cls in classes:
1154 bpy.utils.register_class(cls)
1156 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1157 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1159 bpy.app.handlers.load_post.append(register_driver)
1160 register_driver()
1163 def unregister():
1164 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1165 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1167 # This will only exist if drivers are active
1168 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1169 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1171 bpy.app.handlers.load_post.remove(register_driver)
1172 del bpy.app.driver_namespace['import_image__find_plane_corner']
1174 for cls in classes:
1175 bpy.utils.unregister_class(cls)
1178 if __name__ == "__main__":
1179 # Run simple doc tests
1180 import doctest
1181 doctest.testmod()
1183 unregister()
1184 register()