Export_3ds: Improved distance cue node search
[blender-addons.git] / io_import_images_as_planes.py
blob0298534462902796ed536252c035cb39bf6c8107
1 # SPDX-FileCopyrightText: 2010-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 bl_info = {
6 "name": "Import Images as Planes",
7 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc), mrbimax",
8 "version": (3, 6, 0),
9 "blender": (4, 2, 0),
10 "location": "File > Import > Images as Planes or Add > Image > Images as Planes",
11 "description": "Imports images and creates planes with the appropriate aspect ratio. "
12 "The images are mapped to the planes.",
13 "warning": "",
14 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/images_as_planes.html",
15 "support": 'OFFICIAL',
16 "category": "Import-Export",
19 import os
20 import warnings
21 import re
22 from itertools import count, repeat
23 from collections import namedtuple
24 from math import pi
26 import bpy
27 from bpy.types import Operator
28 from bpy.app.translations import (
29 pgettext_tip as tip_,
30 contexts as i18n_contexts
32 from mathutils import Vector
34 from bpy.props import (
35 StringProperty,
36 BoolProperty,
37 EnumProperty,
38 FloatProperty,
39 CollectionProperty,
42 from bpy_extras.object_utils import (
43 AddObjectHelper,
44 world_to_camera_view,
47 from bpy_extras.image_utils import load_image
48 from bpy_extras.io_utils import ImportHelper
50 # -----------------------------------------------------------------------------
51 # Module-level Shared State
53 watched_objects = {} # used to trigger compositor updates on scene updates
56 # -----------------------------------------------------------------------------
57 # Misc utils.
59 def add_driver_prop(driver, name, type, id, path):
60 """Configure a new driver variable."""
61 dv = driver.variables.new()
62 dv.name = name
63 dv.type = 'SINGLE_PROP'
64 target = dv.targets[0]
65 target.id_type = type
66 target.id = id
67 target.data_path = path
70 # -----------------------------------------------------------------------------
71 # Image loading
73 ImageSpec = namedtuple(
74 'ImageSpec',
75 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
77 num_regex = re.compile('[0-9]') # Find a single number
78 nums_regex = re.compile('[0-9]+') # Find a set of numbers
81 def find_image_sequences(files):
82 """From a group of files, detect image sequences.
84 This returns a generator of tuples, which contain the filename,
85 start frame, and length of the detected sequence
87 >>> list(find_image_sequences([
88 ... "test2-001.jp2", "test2-002.jp2",
89 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
90 ... "blaah"]))
91 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
93 """
94 files = iter(sorted(files))
95 prev_file = None
96 pattern = ""
97 matches = []
98 segment = None
99 length = 1
100 for filename in files:
101 new_pattern = num_regex.sub('#', filename)
102 new_matches = list(map(int, nums_regex.findall(filename)))
103 if new_pattern == pattern:
104 # this file looks like it may be in sequence from the previous
106 # if there are multiple sets of numbers, figure out what changed
107 if segment is None:
108 for i, prev, cur in zip(count(), matches, new_matches):
109 if prev != cur:
110 segment = i
111 break
113 # did it only change by one?
114 for i, prev, cur in zip(count(), matches, new_matches):
115 if i == segment:
116 # We expect this to increment
117 prev = prev + length
118 if prev != cur:
119 break
121 # All good!
122 else:
123 length += 1
124 continue
126 # No continuation -> spit out what we found and reset counters
127 if prev_file:
128 if length > 1:
129 yield prev_file, matches[segment], length
130 else:
131 yield prev_file, 1, 1
133 prev_file = filename
134 matches = new_matches
135 pattern = new_pattern
136 segment = None
137 length = 1
139 if prev_file:
140 if length > 1:
141 yield prev_file, matches[segment], length
142 else:
143 yield prev_file, 1, 1
146 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
147 """Wrapper for bpy's load_image
149 Loads a set of images, movies, or even image sequences
150 Returns a generator of ImageSpec wrapper objects later used for texture setup
152 if find_sequences: # if finding sequences, we need some pre-processing first
153 file_iter = find_image_sequences(filenames)
154 else:
155 file_iter = zip(filenames, repeat(1), repeat(1))
157 for filename, offset, frames in file_iter:
158 if not os.path.isfile(bpy.path.abspath(os.path.join(directory, filename))):
159 continue
161 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
163 # Size is unavailable for sequences, so we grab it early
164 size = tuple(image.size)
166 if image.source == 'MOVIE':
167 # Blender BPY BUG!
168 # This number is only valid when read a second time in 2.77
169 # This repeated line is not a mistake
170 frames = image.frame_duration
171 frames = image.frame_duration
173 elif frames > 1: # Not movie, but multiple frames -> image sequence
174 image.source = 'SEQUENCE'
176 yield ImageSpec(image, size, frame_start, offset - 1, frames)
179 # -----------------------------------------------------------------------------
180 # Position & Size Helpers
182 def offset_planes(planes, gap, axis):
183 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
185 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
186 obj2 0.5 blender units away from obj1 along the local positive Z axis.
188 This is in local space, not world space, so all planes should share
189 a common scale and rotation.
191 prior = planes[0]
192 offset = Vector()
193 for current in planes[1:]:
194 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
196 offset += local_offset * axis
197 current.location = current.matrix_world @ offset
199 prior = current
202 def compute_camera_size(context, center, fill_mode, aspect):
203 """Determine how large an object needs to be to fit or fill the camera's field of view."""
204 scene = context.scene
205 camera = scene.camera
206 view_frame = camera.data.view_frame(scene=scene)
207 frame_size = \
208 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
209 Vector([min(v[i] for v in view_frame) for i in range(3)])
210 camera_aspect = frame_size.x / frame_size.y
212 # Convert the frame size to the correct sizing at a given distance
213 if camera.type == 'ORTHO':
214 frame_size = frame_size.xy
215 else:
216 # Perspective transform
217 distance = world_to_camera_view(scene, camera, center).z
218 frame_size = distance * frame_size.xy / (-view_frame[0].z)
220 # Determine what axis to match to the camera
221 match_axis = 0 # match the Y axis size
222 match_aspect = aspect
223 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
224 (fill_mode == 'FIT' and aspect < camera_aspect):
225 match_axis = 1 # match the X axis size
226 match_aspect = 1.0 / aspect
228 # scale the other axis to the correct aspect
229 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
231 return frame_size
234 def center_in_camera(scene, camera, obj, axis=(1, 1)):
235 """Center object along specified axis of the camera"""
236 camera_matrix_col = camera.matrix_world.col
237 location = obj.location
239 # Vector from the camera's world coordinate center to the object's center
240 delta = camera_matrix_col[3].xyz - location
242 # How far off center we are along the camera's local X
243 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
244 # How far off center we are along the camera's local Y
245 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
247 # Now offset only along camera local axis
248 offset = camera_matrix_col[0].xyz * camera_x_mag + \
249 camera_matrix_col[1].xyz * camera_y_mag
251 obj.location = location + offset
254 # -----------------------------------------------------------------------------
255 # Cycles/Eevee utils
257 def get_input_nodes(node, links):
258 """Get nodes that are a inputs to the given node"""
259 # Get all links going to node.
260 input_links = {lnk for lnk in links if lnk.to_node == node}
261 # Sort those links, get their input nodes (and avoid doubles!).
262 sorted_nodes = []
263 done_nodes = set()
264 for socket in node.inputs:
265 done_links = set()
266 for link in input_links:
267 nd = link.from_node
268 if nd in done_nodes:
269 # Node already treated!
270 done_links.add(link)
271 elif link.to_socket == socket:
272 sorted_nodes.append(nd)
273 done_links.add(link)
274 done_nodes.add(nd)
275 input_links -= done_links
276 return sorted_nodes
279 def auto_align_nodes(node_tree):
280 """Given a shader node tree, arrange nodes neatly relative to the output node."""
281 x_gap = 200
282 y_gap = 180
283 nodes = node_tree.nodes
284 links = node_tree.links
285 output_node = None
286 for node in nodes:
287 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
288 output_node = node
289 break
291 else: # Just in case there is no output
292 return
294 def align(to_node):
295 from_nodes = get_input_nodes(to_node, links)
296 for i, node in enumerate(from_nodes):
297 node.location.x = min(node.location.x, to_node.location.x - x_gap)
298 node.location.y = to_node.location.y
299 node.location.y -= i * y_gap
300 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
301 align(node)
303 align(output_node)
306 def clean_node_tree(node_tree):
307 """Clear all nodes in a shader node tree except the output.
309 Returns the output node
311 nodes = node_tree.nodes
312 for node in list(nodes): # copy to avoid altering the loop's data source
313 if not node.type == 'OUTPUT_MATERIAL':
314 nodes.remove(node)
316 return node_tree.nodes[0]
319 def get_shadeless_node(dest_node_tree):
320 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
321 try:
322 node_tree = bpy.data.node_groups['IAP_SHADELESS']
324 except KeyError:
325 # need to build node shadeless node group
326 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
327 output_node = node_tree.nodes.new('NodeGroupOutput')
328 input_node = node_tree.nodes.new('NodeGroupInput')
330 node_tree.interface.new_socket('Shader', in_out='OUTPUT', socket_type='NodeSocketShader')
331 node_tree.interface.new_socket('Color', in_out='INPUT', socket_type='NodeSocketColor')
333 # This could be faster as a transparent shader, but then no ambient occlusion
334 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
335 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
337 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
338 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
340 light_path = node_tree.nodes.new('ShaderNodeLightPath')
341 is_glossy_ray = light_path.outputs['Is Glossy Ray']
342 is_shadow_ray = light_path.outputs['Is Shadow Ray']
343 ray_depth = light_path.outputs['Ray Depth']
344 transmission_depth = light_path.outputs['Transmission Depth']
346 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
347 unrefracted_depth.operation = 'SUBTRACT'
348 unrefracted_depth.label = 'Bounce Count'
349 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
350 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
352 refracted = node_tree.nodes.new('ShaderNodeMath')
353 refracted.operation = 'SUBTRACT'
354 refracted.label = 'Camera or Refracted'
355 refracted.inputs[0].default_value = 1.0
356 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
358 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
359 reflection_limit.operation = 'SUBTRACT'
360 reflection_limit.label = 'Limit Reflections'
361 reflection_limit.inputs[0].default_value = 2.0
362 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
364 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
365 camera_reflected.operation = 'MULTIPLY'
366 camera_reflected.label = 'Camera Ray to Glossy'
367 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
368 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
370 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
371 shadow_or_reflect.operation = 'MAXIMUM'
372 shadow_or_reflect.label = 'Shadow or Reflection?'
373 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
374 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
376 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
377 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
378 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
379 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
380 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
382 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
383 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
384 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
385 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
387 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
389 auto_align_nodes(node_tree)
391 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
392 group_node.node_tree = node_tree
394 return group_node
397 # -----------------------------------------------------------------------------
398 # Corner Pin Driver Helpers
400 @bpy.app.handlers.persistent
401 def check_drivers(*args, **kwargs):
402 """Check if watched objects in a scene have changed and trigger compositor update
404 This is part of a hack to ensure the compositor updates
405 itself when the objects used for drivers change.
407 It only triggers if transformation matricies change to avoid
408 a cyclic loop of updates.
410 if not watched_objects:
411 # if there is nothing to watch, don't bother running this
412 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
413 return
415 update = False
416 for name, matrix in list(watched_objects.items()):
417 try:
418 obj = bpy.data.objects[name]
419 except KeyError:
420 # The user must have removed this object
421 del watched_objects[name]
422 else:
423 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
424 if new_matrix != matrix:
425 watched_objects[name] = new_matrix
426 update = True
428 if update:
429 # Trick to re-evaluate drivers
430 bpy.context.scene.frame_current = bpy.context.scene.frame_current
433 def register_watched_object(obj):
434 """Register an object to be monitored for transformation changes"""
435 name = obj.name
437 # known object? -> we're done
438 if name in watched_objects:
439 return
441 if not watched_objects:
442 # make sure check_drivers is active
443 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
445 watched_objects[name] = None
448 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
449 """Find the location in camera space of a plane's corner"""
450 if args or kwargs:
451 # I've added args / kwargs as a compatibility measure with future versions
452 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
454 plane = bpy.data.objects[object_name]
456 # Passing in camera doesn't work before 2.78, so we use the current one
457 camera = camera or bpy.context.scene.camera
459 # Hack to ensure compositor updates on future changes
460 register_watched_object(camera)
461 register_watched_object(plane)
463 scale = plane.scale * 2.0
464 v = plane.dimensions.copy()
465 v.x *= x / scale.x
466 v.y *= y / scale.y
467 v = plane.matrix_world @ v
469 camera_vertex = world_to_camera_view(
470 bpy.context.scene, camera, v)
472 return camera_vertex[axis]
475 @bpy.app.handlers.persistent
476 def register_driver(*args, **kwargs):
477 """Register the find_plane_corner function for use with drivers"""
478 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
481 # -----------------------------------------------------------------------------
482 # Compositing Helpers
484 def group_in_frame(node_tree, name, nodes):
485 frame_node = node_tree.nodes.new("NodeFrame")
486 frame_node.label = name
487 frame_node.name = name + "_frame"
489 min_pos = Vector(nodes[0].location)
490 max_pos = min_pos.copy()
492 for node in nodes:
493 top_left = node.location
494 bottom_right = top_left + Vector((node.width, -node.height))
496 for i in (0, 1):
497 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
498 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
500 node.parent = frame_node
502 frame_node.width = max_pos[0] - min_pos[0] + 50
503 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
504 frame_node.shrink = True
506 return frame_node
509 def position_frame_bottom_left(node_tree, frame_node):
510 newpos = Vector((100000, 100000)) # start reasonably far top / right
512 # Align with the furthest left
513 for node in node_tree.nodes.values():
514 if node != frame_node and node.parent != frame_node:
515 newpos.x = min(newpos.x, node.location.x + 30)
517 # As high as we can get without overlapping anything to the right
518 for node in node_tree.nodes.values():
519 if node != frame_node and not node.parent:
520 if node.location.x < newpos.x + frame_node.width:
521 print("Below", node.name, node.location, node.height, node.dimensions)
522 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
524 frame_node.location = newpos
527 def setup_compositing(context, plane, img_spec):
528 # Node Groups only work with "new" dependency graph and even
529 # then it has some problems with not updating the first time
530 # So instead this groups with a node frame, which works reliably
532 scene = context.scene
533 scene.use_nodes = True
534 node_tree = scene.node_tree
535 name = plane.name
537 image_node = node_tree.nodes.new("CompositorNodeImage")
538 image_node.name = name + "_image"
539 image_node.image = img_spec.image
540 image_node.location = Vector((0, 0))
541 image_node.frame_start = img_spec.frame_start
542 image_node.frame_offset = img_spec.frame_offset
543 image_node.frame_duration = img_spec.frame_duration
545 scale_node = node_tree.nodes.new("CompositorNodeScale")
546 scale_node.name = name + "_scale"
547 scale_node.space = 'RENDER_SIZE'
548 scale_node.location = image_node.location + \
549 Vector((image_node.width + 20, 0))
550 scale_node.show_options = False
552 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
553 cornerpin_node.name = name + "_cornerpin"
554 cornerpin_node.location = scale_node.location + \
555 Vector((0, -scale_node.height))
557 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
558 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
560 # Put all the nodes in a frame for organization
561 frame_node = group_in_frame(
562 node_tree, name,
563 (image_node, scale_node, cornerpin_node)
566 # Position frame at bottom / left
567 position_frame_bottom_left(node_tree, frame_node)
569 # Configure Drivers
570 for corner in cornerpin_node.inputs[1:]:
571 id = corner.identifier
572 x = -1 if 'Left' in id else 1
573 y = -1 if 'Lower' in id else 1
574 drivers = corner.driver_add('default_value')
575 for i, axis_fcurve in enumerate(drivers):
576 driver = axis_fcurve.driver
577 # Always use the current camera
578 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
579 # Track camera location to ensure Deps Graph triggers (not used in the call)
580 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
581 # Don't break if the name changes
582 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
583 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
584 repr(plane.name),
585 x, y, i
587 driver.type = 'SCRIPTED'
588 driver.is_valid = True
589 axis_fcurve.is_valid = True
590 driver.expression = "%s" % driver.expression
592 context.view_layer.update()
595 # -----------------------------------------------------------------------------
596 # Operator
598 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper, ImportHelper):
599 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
601 bl_idname = "import_image.to_plane"
602 bl_label = "Import Images as Planes"
603 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
605 # ----------------------
606 # File dialog properties
607 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
609 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
611 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
612 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
613 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
615 # ----------------------
616 # Properties - Importing
617 force_reload: BoolProperty(
618 name="Force Reload", default=False,
619 description="Force reloading of the image if already opened elsewhere in Blender"
622 image_sequence: BoolProperty(
623 name="Animate Image Sequences", default=False,
624 description="Import sequentially numbered images as an animated "
625 "image sequence instead of separate planes"
628 # -------------------------------------
629 # Properties - Position and Orientation
630 axis_id_to_vector = {
631 'X+': Vector(( 1, 0, 0)),
632 'Y+': Vector(( 0, 1, 0)),
633 'Z+': Vector(( 0, 0, 1)),
634 'X-': Vector((-1, 0, 0)),
635 'Y-': Vector(( 0, -1, 0)),
636 'Z-': Vector(( 0, 0, -1)),
639 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
641 OFFSET_MODES = (
642 ('X+', "X+", "Side by Side to the Left"),
643 ('Y+', "Y+", "Side by Side, Downward"),
644 ('Z+', "Z+", "Stacked Above"),
645 ('X-', "X-", "Side by Side to the Right"),
646 ('Y-', "Y-", "Side by Side, Upward"),
647 ('Z-', "Z-", "Stacked Below"),
649 offset_axis: EnumProperty(
650 name="Orientation", default='X+', items=OFFSET_MODES,
651 description="How planes are oriented relative to each others' local axis"
654 offset_amount: FloatProperty(
655 name="Offset", soft_min=0, default=0.1, description="Space between planes",
656 subtype='DISTANCE', unit='LENGTH'
659 AXIS_MODES = (
660 ('X+', "X+", "Facing Positive X"),
661 ('Y+', "Y+", "Facing Positive Y"),
662 ('Z+', "Z+ (Up)", "Facing Positive Z"),
663 ('X-', "X-", "Facing Negative X"),
664 ('Y-', "Y-", "Facing Negative Y"),
665 ('Z-', "Z- (Down)", "Facing Negative Z"),
666 ('CAM', "Face Camera", "Facing Camera"),
667 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
669 align_axis: EnumProperty(
670 name="Align", default='CAM_AX', items=AXIS_MODES,
671 description="How to align the planes"
673 # prev_align_axis is used only by update_size_model
674 prev_align_axis: EnumProperty(
675 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
676 align_track: BoolProperty(
677 name="Track Camera", default=False, description="Always face the camera"
680 # -----------------
681 # Properties - Size
682 def update_size_mode(self, context):
683 """If sizing relative to the camera, always face the camera"""
684 if self.size_mode == 'CAMERA':
685 self.prev_align_axis = self.align_axis
686 self.align_axis = 'CAM'
687 else:
688 # if a different alignment was set revert to that when
689 # size mode is changed
690 if self.prev_align_axis != 'NONE':
691 self.align_axis = self.prev_align_axis
692 self._prev_align_axis = 'NONE'
694 SIZE_MODES = (
695 ('ABSOLUTE', "Absolute", "Use absolute size"),
696 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
697 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
698 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
700 size_mode: EnumProperty(
701 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
702 update=update_size_mode,
703 description="How the size of the plane is computed")
705 FILL_MODES = (
706 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
707 ('FIT', "Fit", "Fit entire image within the camera frame"),
709 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
710 description="How large in the camera frame is the plane")
712 height: FloatProperty(name="Height", description="Height of the created plane",
713 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
715 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
716 description="Number of pixels per inch or Blender Unit")
718 # ------------------------------
719 # Properties - Material / Shader
720 SHADERS = (
721 ('PRINCIPLED',"Principled","Principled Shader"),
722 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
723 ('EMISSION', "Emit", "Emission Shader"),
725 shader: EnumProperty(name="Shader", items=SHADERS, default='PRINCIPLED', description="Node shader to use")
727 emit_strength: FloatProperty(
728 name="Strength", min=0.0, default=1.0, soft_max=10.0,
729 step=100, description="Brightness of Emission Texture")
731 use_transparency: BoolProperty(
732 name="Use Alpha", default=True,
733 description="Use alpha channel for transparency")
735 BLEND_METHODS = (
736 ('BLEND',"Blend","Render polygon transparent, depending on alpha channel of the texture"),
737 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
738 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
739 ('OPAQUE', "Opaque","Render surface without transparency"),
741 blend_method: EnumProperty(
742 name="Blend Mode", items=BLEND_METHODS, default='BLEND',
743 description="Blend Mode for Transparent Faces", translation_context=i18n_contexts.id_material)
745 SHADOW_METHODS = (
746 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
747 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
748 ('OPAQUE',"Opaque","Material will cast shadows without transparency"),
749 ('NONE',"None","Material will cast no shadow"),
751 shadow_method: EnumProperty(
752 name="Shadow Mode", items=SHADOW_METHODS, default='CLIP',
753 description="Shadow mapping method", translation_context=i18n_contexts.id_material)
755 use_backface_culling: BoolProperty(
756 name="Backface Culling", default=False,
757 description="Use back face culling to hide the back side of faces")
759 show_transparent_back: BoolProperty(
760 name="Show Backface", default=True,
761 description="Render multiple transparent layers (may introduce transparency sorting problems)")
763 overwrite_material: BoolProperty(
764 name="Overwrite Material", default=True,
765 description="Overwrite existing Material (based on material name)")
767 compositing_nodes: BoolProperty(
768 name="Setup Corner Pin", default=False,
769 description="Build Compositor Nodes to reference this image "
770 "without re-rendering")
772 # ------------------
773 # Properties - Image
774 INTERPOLATION_MODES = (
775 ('Linear', "Linear", "Linear interpolation"),
776 ('Closest', "Closest", "No interpolation (sample closest texel)"),
777 ('Cubic', "Cubic", "Cubic interpolation"),
778 ('Smart', "Smart", "Bicubic when magnifying, else bilinear (OSL only)"),
780 interpolation: EnumProperty(name="Interpolation", items=INTERPOLATION_MODES, default='Linear', description="Texture interpolation")
782 EXTENSION_MODES = (
783 ('CLIP', "Clip", "Clip to image size and set exterior pixels as transparent"),
784 ('EXTEND', "Extend", "Extend by repeating edge pixels of the image"),
785 ('REPEAT', "Repeat", "Cause the image to repeat horizontally and vertically"),
787 extension: EnumProperty(name="Extension", items=EXTENSION_MODES, default='CLIP', description="How the image is extrapolated past its original bounds")
789 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
790 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
791 alpha_mode: EnumProperty(
792 name=t.name, items=alpha_mode_items, default=t.default,
793 description=t.description)
795 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
796 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
798 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
800 # -------
801 # Draw UI
802 def draw_import_config(self, context):
803 # --- Import Options --- #
804 layout = self.layout
805 box = layout.box()
807 box.label(text="Import Options:", icon='IMPORT')
808 row = box.row()
809 row.active = bpy.data.is_saved
810 row.prop(self, "relative")
812 box.prop(self, "force_reload")
813 box.prop(self, "image_sequence")
815 def draw_material_config(self, context):
816 # --- Material / Rendering Properties --- #
817 layout = self.layout
818 box = layout.box()
820 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
821 box.prop(self, "compositing_nodes")
822 layout = self.layout
823 box = layout.box()
824 box.label(text="Material Settings:", icon='MATERIAL')
826 box.label(text="Material Type")
827 row = box.row()
828 row.prop(self, 'shader', expand=True)
829 if self.shader == 'EMISSION':
830 box.prop(self, "emit_strength")
832 box.label(text="Blend Mode")
833 row = box.row()
834 row.prop(self, 'blend_method', expand=True)
835 if self.use_transparency and self.alpha_mode != "NONE" and self.blend_method == "OPAQUE":
836 box.label(text="'Opaque' does not support alpha", icon="ERROR")
837 if self.blend_method == 'BLEND':
838 row = box.row()
839 row.prop(self, "show_transparent_back")
841 box.label(text="Shadow Mode")
842 row = box.row()
843 row.prop(self, 'shadow_method', expand=True)
845 row = box.row()
846 row.prop(self, "use_backface_culling")
848 engine = context.scene.render.engine
849 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
850 box.label(text=tip_("%s is not supported") % engine, icon='ERROR')
852 box.prop(self, "overwrite_material")
853 layout = self.layout
854 box = layout.box()
855 box.label(text="Texture Settings:", icon='TEXTURE')
856 box.label(text="Interpolation")
857 row = box.row()
858 row.prop(self, 'interpolation', expand=True)
859 box.label(text="Extension")
860 row = box.row()
861 row.prop(self, 'extension', expand=True)
862 row = box.row()
863 row.prop(self, "use_transparency")
864 if self.use_transparency:
865 sub = row.row()
866 sub.prop(self, "alpha_mode", text="")
867 row = box.row()
868 row.prop(self, "use_auto_refresh")
870 def draw_spatial_config(self, context):
871 # --- Spatial Properties: Position, Size and Orientation --- #
872 layout = self.layout
873 box = layout.box()
875 box.label(text="Position:", icon='SNAP_GRID')
876 box.prop(self, "offset")
877 col = box.column()
878 row = col.row()
879 row.prop(self, "offset_axis", expand=True)
880 row = col.row()
881 row.prop(self, "offset_amount")
882 col.enabled = self.offset
884 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
885 row = box.row()
886 row.prop(self, "size_mode", expand=True)
887 if self.size_mode == 'ABSOLUTE':
888 box.prop(self, "height")
889 elif self.size_mode == 'CAMERA':
890 row = box.row()
891 row.prop(self, "fill_mode", expand=True)
892 else:
893 box.prop(self, "factor")
895 box.label(text="Orientation:")
896 row = box.row()
897 row.enabled = 'CAM' not in self.size_mode
898 row.prop(self, "align_axis")
899 row = box.row()
900 row.enabled = 'CAM' in self.align_axis
901 row.alignment = 'RIGHT'
902 row.prop(self, "align_track")
904 def draw(self, context):
906 # Draw configuration sections
907 self.draw_import_config(context)
908 self.draw_material_config(context)
909 self.draw_spatial_config(context)
911 # -------------------------------------------------------------------------
912 # Core functionality
913 def invoke(self, context, event):
914 engine = context.scene.render.engine
915 if engine not in {'CYCLES', 'BLENDER_EEVEE','BLENDER_EEVEE_NEXT'}:
916 if engine != 'BLENDER_WORKBENCH':
917 self.report({'ERROR'}, tip_("Cannot generate materials for unknown %s render engine") % engine)
918 return {'CANCELLED'}
919 else:
920 self.report({'WARNING'},
921 tip_("Generating Cycles/EEVEE compatible material, but won't be visible with %s engine") % engine)
923 return self.invoke_popup(context)
925 def execute(self, context):
926 if not bpy.data.is_saved:
927 self.relative = False
929 # this won't work in edit mode
930 editmode = context.preferences.edit.use_enter_edit_mode
931 context.preferences.edit.use_enter_edit_mode = False
932 if context.active_object and context.active_object.mode != 'OBJECT':
933 bpy.ops.object.mode_set(mode='OBJECT')
935 ret_code = self.import_images(context)
937 context.preferences.edit.use_enter_edit_mode = editmode
939 return ret_code
941 def import_images(self, context):
943 # load images / sequences
944 images = tuple(load_images(
945 (fn.name for fn in self.files),
946 self.directory,
947 force_reload=self.force_reload,
948 find_sequences=self.image_sequence
951 if not images:
952 self.report({'WARNING'}, "Please select at least one image")
953 return {'CANCELLED'}
955 # Create individual planes
956 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
958 context.view_layer.update()
960 # Align planes relative to each other
961 if self.offset:
962 offset_axis = self.axis_id_to_vector[self.offset_axis]
963 offset_planes(planes, self.offset_amount, offset_axis)
965 if self.size_mode == 'CAMERA' and offset_axis.z:
966 for plane in planes:
967 x, y = compute_camera_size(
968 context, plane.location,
969 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
970 plane.dimensions = x, y, 0.0
972 # setup new selection
973 for plane in planes:
974 plane.select_set(True)
976 # all done!
977 self.report({'INFO'}, tip_("Added {} Image Plane(s)").format(len(planes)))
978 return {'FINISHED'}
980 # operate on a single image
981 def single_image_spec_to_plane(self, context, img_spec):
983 # Configure image
984 self.apply_image_options(img_spec.image)
986 # Configure material
987 engine = context.scene.render.engine
988 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT', 'BLENDER_WORKBENCH'}:
989 material = self.create_cycles_material(context, img_spec)
991 # Create and position plane object
992 plane = self.create_image_plane(context, material.name, img_spec)
994 # Assign Material
995 plane.data.materials.append(material)
997 # If applicable, setup Corner Pin node
998 if self.compositing_nodes:
999 setup_compositing(context, plane, img_spec)
1001 return plane
1003 def apply_image_options(self, image):
1004 if self.use_transparency == False:
1005 image.alpha_mode = 'NONE'
1006 else:
1007 image.alpha_mode = self.alpha_mode
1009 if self.relative:
1010 try: # can't always find the relative path (between drive letters on windows)
1011 image.filepath = bpy.path.relpath(image.filepath)
1012 except ValueError:
1013 pass
1015 def apply_texture_options(self, texture, img_spec):
1016 # Shared by both Cycles and Blender Internal
1017 image_user = texture.image_user
1018 image_user.use_auto_refresh = self.use_auto_refresh
1019 image_user.frame_start = img_spec.frame_start
1020 image_user.frame_offset = img_spec.frame_offset
1021 image_user.frame_duration = img_spec.frame_duration
1023 # Image sequences need auto refresh to display reliably
1024 if img_spec.image.source == 'SEQUENCE':
1025 image_user.use_auto_refresh = True
1027 def apply_material_options(self, material, slot):
1028 shader = self.shader
1030 if self.use_transparency:
1031 material.alpha = 0.0
1032 material.specular_alpha = 0.0
1033 slot.use_map_alpha = True
1034 else:
1035 material.alpha = 1.0
1036 material.specular_alpha = 1.0
1037 slot.use_map_alpha = False
1039 material.specular_intensity = 0
1040 material.diffuse_intensity = 1.0
1041 material.use_transparency = self.use_transparency
1042 material.transparency_method = 'Z_TRANSPARENCY'
1043 material.use_shadeless = (shader == 'SHADELESS')
1044 material.use_transparent_shadows = (shader == 'DIFFUSE')
1045 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
1047 # -------------------------------------------------------------------------
1048 # Cycles/Eevee
1049 def create_cycles_texnode(self, context, node_tree, img_spec):
1050 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
1051 tex_image.image = img_spec.image
1052 tex_image.show_texture = True
1053 tex_image.interpolation = self.interpolation
1054 tex_image.extension = self.extension
1055 self.apply_texture_options(tex_image, img_spec)
1056 return tex_image
1058 def create_cycles_material(self, context, img_spec):
1059 image = img_spec.image
1060 name_compat = bpy.path.display_name_from_filepath(image.filepath)
1061 material = None
1062 if self.overwrite_material:
1063 for mat in bpy.data.materials:
1064 if mat.name == name_compat:
1065 material = mat
1066 if not material:
1067 material = bpy.data.materials.new(name=name_compat)
1069 material.use_nodes = True
1071 material.blend_method = self.blend_method
1072 material.shadow_method = self.shadow_method
1074 material.use_backface_culling = self.use_backface_culling
1075 material.show_transparent_back = self.show_transparent_back
1077 node_tree = material.node_tree
1078 out_node = clean_node_tree(node_tree)
1080 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1082 if self.shader == 'PRINCIPLED':
1083 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1084 elif self.shader == 'SHADELESS':
1085 core_shader = get_shadeless_node(node_tree)
1086 elif self.shader == 'EMISSION':
1087 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1088 core_shader.inputs['Emission Strength'].default_value = self.emit_strength
1089 core_shader.inputs['Base Color'].default_value = (0.0, 0.0, 0.0, 1.0)
1090 core_shader.inputs['Specular IOR Level'].default_value = 0.0
1092 # Connect color from texture
1093 if self.shader in {'PRINCIPLED', 'SHADELESS'}:
1094 node_tree.links.new(core_shader.inputs[0], tex_image.outputs['Color'])
1095 elif self.shader == 'EMISSION':
1096 node_tree.links.new(core_shader.inputs['Emission Color'], tex_image.outputs['Color'])
1098 if self.use_transparency:
1099 if self.shader in {'PRINCIPLED', 'EMISSION'}:
1100 node_tree.links.new(core_shader.inputs['Alpha'], tex_image.outputs['Alpha'])
1101 else:
1102 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1104 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1105 node_tree.links.new(mix_shader.inputs['Fac'], tex_image.outputs['Alpha'])
1106 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs['BSDF'])
1107 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1108 core_shader = mix_shader
1110 node_tree.links.new(out_node.inputs['Surface'], core_shader.outputs[0])
1112 auto_align_nodes(node_tree)
1113 return material
1115 # -------------------------------------------------------------------------
1116 # Geometry Creation
1117 def create_image_plane(self, context, name, img_spec):
1119 width, height = self.compute_plane_size(context, img_spec)
1121 # Create new mesh
1122 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1123 plane = context.active_object
1124 # Why does mesh.primitive_plane_add leave the object in edit mode???
1125 if plane.mode != 'OBJECT':
1126 bpy.ops.object.mode_set(mode='OBJECT')
1127 plane.dimensions = width, height, 0.0
1128 plane.data.name = plane.name = name
1129 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1131 # If sizing for camera, also insert into the camera's field of view
1132 if self.size_mode == 'CAMERA':
1133 offset_axis = self.axis_id_to_vector[self.offset_axis]
1134 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1135 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1137 self.align_plane(context, plane)
1139 return plane
1141 def compute_plane_size(self, context, img_spec):
1142 """Given the image size in pixels and location, determine size of plane"""
1143 px, py = img_spec.size
1145 # can't load data
1146 if px == 0 or py == 0:
1147 px = py = 1
1149 if self.size_mode == 'ABSOLUTE':
1150 y = self.height
1151 x = px / py * y
1153 elif self.size_mode == 'CAMERA':
1154 x, y = compute_camera_size(
1155 context, context.scene.cursor.location,
1156 self.fill_mode, px / py
1159 elif self.size_mode == 'DPI':
1160 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1161 x = px * fact
1162 y = py * fact
1164 else: # elif self.size_mode == 'DPBU'
1165 fact = 1 / self.factor
1166 x = px * fact
1167 y = py * fact
1169 return x, y
1171 def align_plane(self, context, plane):
1172 """Pick an axis and align the plane to it"""
1173 if 'CAM' in self.align_axis:
1174 # Camera-aligned
1175 camera = context.scene.camera
1176 if (camera):
1177 # Find the axis that best corresponds to the camera's view direction
1178 axis = camera.matrix_world @ \
1179 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1180 # pick the axis with the greatest magnitude
1181 mag = max(map(abs, axis))
1182 # And use that axis & direction
1183 axis = Vector([
1184 n / mag if abs(n) == mag else 0.0
1185 for n in axis
1187 else:
1188 # No camera? Just face Z axis
1189 axis = Vector((0, 0, 1))
1190 self.align_axis = 'Z+'
1191 else:
1192 # Axis-aligned
1193 axis = self.axis_id_to_vector[self.align_axis]
1195 # rotate accordingly for x/y axiis
1196 if not axis.z:
1197 plane.rotation_euler.x = pi / 2
1199 if axis.y > 0:
1200 plane.rotation_euler.z = pi
1201 elif axis.y < 0:
1202 plane.rotation_euler.z = 0
1203 elif axis.x > 0:
1204 plane.rotation_euler.z = pi / 2
1205 elif axis.x < 0:
1206 plane.rotation_euler.z = -pi / 2
1208 # or flip 180 degrees for negative z
1209 elif axis.z < 0:
1210 plane.rotation_euler.y = pi
1212 if self.align_axis == 'CAM':
1213 constraint = plane.constraints.new('COPY_ROTATION')
1214 constraint.target = camera
1215 constraint.use_x = constraint.use_y = constraint.use_z = True
1216 if not self.align_track:
1217 bpy.ops.object.visual_transform_apply()
1218 plane.constraints.clear()
1220 if self.align_axis == 'CAM_AX' and self.align_track:
1221 constraint = plane.constraints.new('LOCKED_TRACK')
1222 constraint.target = camera
1223 constraint.track_axis = 'TRACK_Z'
1224 constraint.lock_axis = 'LOCK_Y'
1227 class IMPORT_IMAGE_FH_to_plane(bpy.types.FileHandler):
1228 bl_idname = "IMPORT_IMAGE_FH_to_plane"
1229 bl_label = "File handler for images as planes import"
1230 bl_import_operator = "import_image.to_plane"
1231 bl_file_extensions = ";".join(bpy.path.extensions_image.union(bpy.path.extensions_movie))
1233 @classmethod
1234 def poll_drop(cls, context):
1235 return (context.region and context.region.type == 'WINDOW'
1236 and context.area and context.area.ui_type == 'VIEW_3D')
1239 # -----------------------------------------------------------------------------
1240 # Register
1242 def import_images_button(self, context):
1243 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1246 classes = (
1247 IMPORT_IMAGE_OT_to_plane,
1248 IMPORT_IMAGE_FH_to_plane,
1252 def register():
1253 for cls in classes:
1254 bpy.utils.register_class(cls)
1256 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1257 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1259 bpy.app.handlers.load_post.append(register_driver)
1260 register_driver()
1263 def unregister():
1264 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1265 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1267 # This will only exist if drivers are active
1268 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1269 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1271 bpy.app.handlers.load_post.remove(register_driver)
1272 del bpy.app.driver_namespace['import_image__find_plane_corner']
1274 for cls in classes:
1275 bpy.utils.unregister_class(cls)
1278 if __name__ == "__main__":
1279 # Run simple doc tests
1280 import doctest
1281 doctest.testmod()
1283 unregister()
1284 register()