io_scene_3ds: Bump version for auto smooth removal
[blender-addons.git] / io_import_images_as_planes.py
blobeb8420ff4deebc5e7ded4d5d0e2855cb8543c5d0
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, 5, 1),
9 "blender": (4, 0, 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
49 # -----------------------------------------------------------------------------
50 # Module-level Shared State
52 watched_objects = {} # used to trigger compositor updates on scene updates
55 # -----------------------------------------------------------------------------
56 # Misc utils.
58 def add_driver_prop(driver, name, type, id, path):
59 """Configure a new driver variable."""
60 dv = driver.variables.new()
61 dv.name = name
62 dv.type = 'SINGLE_PROP'
63 target = dv.targets[0]
64 target.id_type = type
65 target.id = id
66 target.data_path = path
69 # -----------------------------------------------------------------------------
70 # Image loading
72 ImageSpec = namedtuple(
73 'ImageSpec',
74 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
76 num_regex = re.compile('[0-9]') # Find a single number
77 nums_regex = re.compile('[0-9]+') # Find a set of numbers
80 def find_image_sequences(files):
81 """From a group of files, detect image sequences.
83 This returns a generator of tuples, which contain the filename,
84 start frame, and length of the detected sequence
86 >>> list(find_image_sequences([
87 ... "test2-001.jp2", "test2-002.jp2",
88 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
89 ... "blaah"]))
90 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
92 """
93 files = iter(sorted(files))
94 prev_file = None
95 pattern = ""
96 matches = []
97 segment = None
98 length = 1
99 for filename in files:
100 new_pattern = num_regex.sub('#', filename)
101 new_matches = list(map(int, nums_regex.findall(filename)))
102 if new_pattern == pattern:
103 # this file looks like it may be in sequence from the previous
105 # if there are multiple sets of numbers, figure out what changed
106 if segment is None:
107 for i, prev, cur in zip(count(), matches, new_matches):
108 if prev != cur:
109 segment = i
110 break
112 # did it only change by one?
113 for i, prev, cur in zip(count(), matches, new_matches):
114 if i == segment:
115 # We expect this to increment
116 prev = prev + length
117 if prev != cur:
118 break
120 # All good!
121 else:
122 length += 1
123 continue
125 # No continuation -> spit out what we found and reset counters
126 if prev_file:
127 if length > 1:
128 yield prev_file, matches[segment], length
129 else:
130 yield prev_file, 1, 1
132 prev_file = filename
133 matches = new_matches
134 pattern = new_pattern
135 segment = None
136 length = 1
138 if prev_file:
139 if length > 1:
140 yield prev_file, matches[segment], length
141 else:
142 yield prev_file, 1, 1
145 def load_images(filenames, directory, force_reload=False, frame_start=1, find_sequences=False):
146 """Wrapper for bpy's load_image
148 Loads a set of images, movies, or even image sequences
149 Returns a generator of ImageSpec wrapper objects later used for texture setup
151 if find_sequences: # if finding sequences, we need some pre-processing first
152 file_iter = find_image_sequences(filenames)
153 else:
154 file_iter = zip(filenames, repeat(1), repeat(1))
156 for filename, offset, frames in file_iter:
157 if not os.path.isfile(bpy.path.abspath(os.path.join(directory, filename))):
158 continue
160 image = load_image(filename, directory, check_existing=True, force_reload=force_reload)
162 # Size is unavailable for sequences, so we grab it early
163 size = tuple(image.size)
165 if image.source == 'MOVIE':
166 # Blender BPY BUG!
167 # This number is only valid when read a second time in 2.77
168 # This repeated line is not a mistake
169 frames = image.frame_duration
170 frames = image.frame_duration
172 elif frames > 1: # Not movie, but multiple frames -> image sequence
173 image.source = 'SEQUENCE'
175 yield ImageSpec(image, size, frame_start, offset - 1, frames)
178 # -----------------------------------------------------------------------------
179 # Position & Size Helpers
181 def offset_planes(planes, gap, axis):
182 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
184 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
185 obj2 0.5 blender units away from obj1 along the local positive Z axis.
187 This is in local space, not world space, so all planes should share
188 a common scale and rotation.
190 prior = planes[0]
191 offset = Vector()
192 for current in planes[1:]:
193 local_offset = abs((prior.dimensions + current.dimensions).dot(axis)) / 2.0 + gap
195 offset += local_offset * axis
196 current.location = current.matrix_world @ offset
198 prior = current
201 def compute_camera_size(context, center, fill_mode, aspect):
202 """Determine how large an object needs to be to fit or fill the camera's field of view."""
203 scene = context.scene
204 camera = scene.camera
205 view_frame = camera.data.view_frame(scene=scene)
206 frame_size = \
207 Vector([max(v[i] for v in view_frame) for i in range(3)]) - \
208 Vector([min(v[i] for v in view_frame) for i in range(3)])
209 camera_aspect = frame_size.x / frame_size.y
211 # Convert the frame size to the correct sizing at a given distance
212 if camera.type == 'ORTHO':
213 frame_size = frame_size.xy
214 else:
215 # Perspective transform
216 distance = world_to_camera_view(scene, camera, center).z
217 frame_size = distance * frame_size.xy / (-view_frame[0].z)
219 # Determine what axis to match to the camera
220 match_axis = 0 # match the Y axis size
221 match_aspect = aspect
222 if (fill_mode == 'FILL' and aspect > camera_aspect) or \
223 (fill_mode == 'FIT' and aspect < camera_aspect):
224 match_axis = 1 # match the X axis size
225 match_aspect = 1.0 / aspect
227 # scale the other axis to the correct aspect
228 frame_size[1 - match_axis] = frame_size[match_axis] / match_aspect
230 return frame_size
233 def center_in_camera(scene, camera, obj, axis=(1, 1)):
234 """Center object along specified axis of the camera"""
235 camera_matrix_col = camera.matrix_world.col
236 location = obj.location
238 # Vector from the camera's world coordinate center to the object's center
239 delta = camera_matrix_col[3].xyz - location
241 # How far off center we are along the camera's local X
242 camera_x_mag = delta.dot(camera_matrix_col[0].xyz) * axis[0]
243 # How far off center we are along the camera's local Y
244 camera_y_mag = delta.dot(camera_matrix_col[1].xyz) * axis[1]
246 # Now offset only along camera local axis
247 offset = camera_matrix_col[0].xyz * camera_x_mag + \
248 camera_matrix_col[1].xyz * camera_y_mag
250 obj.location = location + offset
253 # -----------------------------------------------------------------------------
254 # Cycles/Eevee utils
256 def get_input_nodes(node, links):
257 """Get nodes that are a inputs to the given node"""
258 # Get all links going to node.
259 input_links = {lnk for lnk in links if lnk.to_node == node}
260 # Sort those links, get their input nodes (and avoid doubles!).
261 sorted_nodes = []
262 done_nodes = set()
263 for socket in node.inputs:
264 done_links = set()
265 for link in input_links:
266 nd = link.from_node
267 if nd in done_nodes:
268 # Node already treated!
269 done_links.add(link)
270 elif link.to_socket == socket:
271 sorted_nodes.append(nd)
272 done_links.add(link)
273 done_nodes.add(nd)
274 input_links -= done_links
275 return sorted_nodes
278 def auto_align_nodes(node_tree):
279 """Given a shader node tree, arrange nodes neatly relative to the output node."""
280 x_gap = 200
281 y_gap = 180
282 nodes = node_tree.nodes
283 links = node_tree.links
284 output_node = None
285 for node in nodes:
286 if node.type == 'OUTPUT_MATERIAL' or node.type == 'GROUP_OUTPUT':
287 output_node = node
288 break
290 else: # Just in case there is no output
291 return
293 def align(to_node):
294 from_nodes = get_input_nodes(to_node, links)
295 for i, node in enumerate(from_nodes):
296 node.location.x = min(node.location.x, to_node.location.x - x_gap)
297 node.location.y = to_node.location.y
298 node.location.y -= i * y_gap
299 node.location.y += (len(from_nodes) - 1) * y_gap / (len(from_nodes))
300 align(node)
302 align(output_node)
305 def clean_node_tree(node_tree):
306 """Clear all nodes in a shader node tree except the output.
308 Returns the output node
310 nodes = node_tree.nodes
311 for node in list(nodes): # copy to avoid altering the loop's data source
312 if not node.type == 'OUTPUT_MATERIAL':
313 nodes.remove(node)
315 return node_tree.nodes[0]
318 def get_shadeless_node(dest_node_tree):
319 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
320 try:
321 node_tree = bpy.data.node_groups['IAP_SHADELESS']
323 except KeyError:
324 # need to build node shadeless node group
325 node_tree = bpy.data.node_groups.new('IAP_SHADELESS', 'ShaderNodeTree')
326 output_node = node_tree.nodes.new('NodeGroupOutput')
327 input_node = node_tree.nodes.new('NodeGroupInput')
329 node_tree.interface.new_socket('Shader', in_out='OUTPUT', socket_type='NodeSocketShader')
330 node_tree.interface.new_socket('Color', in_out='INPUT', socket_type='NodeSocketColor')
332 # This could be faster as a transparent shader, but then no ambient occlusion
333 diffuse_shader = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
334 node_tree.links.new(diffuse_shader.inputs[0], input_node.outputs[0])
336 emission_shader = node_tree.nodes.new('ShaderNodeEmission')
337 node_tree.links.new(emission_shader.inputs[0], input_node.outputs[0])
339 light_path = node_tree.nodes.new('ShaderNodeLightPath')
340 is_glossy_ray = light_path.outputs['Is Glossy Ray']
341 is_shadow_ray = light_path.outputs['Is Shadow Ray']
342 ray_depth = light_path.outputs['Ray Depth']
343 transmission_depth = light_path.outputs['Transmission Depth']
345 unrefracted_depth = node_tree.nodes.new('ShaderNodeMath')
346 unrefracted_depth.operation = 'SUBTRACT'
347 unrefracted_depth.label = 'Bounce Count'
348 node_tree.links.new(unrefracted_depth.inputs[0], ray_depth)
349 node_tree.links.new(unrefracted_depth.inputs[1], transmission_depth)
351 refracted = node_tree.nodes.new('ShaderNodeMath')
352 refracted.operation = 'SUBTRACT'
353 refracted.label = 'Camera or Refracted'
354 refracted.inputs[0].default_value = 1.0
355 node_tree.links.new(refracted.inputs[1], unrefracted_depth.outputs[0])
357 reflection_limit = node_tree.nodes.new('ShaderNodeMath')
358 reflection_limit.operation = 'SUBTRACT'
359 reflection_limit.label = 'Limit Reflections'
360 reflection_limit.inputs[0].default_value = 2.0
361 node_tree.links.new(reflection_limit.inputs[1], ray_depth)
363 camera_reflected = node_tree.nodes.new('ShaderNodeMath')
364 camera_reflected.operation = 'MULTIPLY'
365 camera_reflected.label = 'Camera Ray to Glossy'
366 node_tree.links.new(camera_reflected.inputs[0], reflection_limit.outputs[0])
367 node_tree.links.new(camera_reflected.inputs[1], is_glossy_ray)
369 shadow_or_reflect = node_tree.nodes.new('ShaderNodeMath')
370 shadow_or_reflect.operation = 'MAXIMUM'
371 shadow_or_reflect.label = 'Shadow or Reflection?'
372 node_tree.links.new(shadow_or_reflect.inputs[0], camera_reflected.outputs[0])
373 node_tree.links.new(shadow_or_reflect.inputs[1], is_shadow_ray)
375 shadow_or_reflect_or_refract = node_tree.nodes.new('ShaderNodeMath')
376 shadow_or_reflect_or_refract.operation = 'MAXIMUM'
377 shadow_or_reflect_or_refract.label = 'Shadow, Reflect or Refract?'
378 node_tree.links.new(shadow_or_reflect_or_refract.inputs[0], shadow_or_reflect.outputs[0])
379 node_tree.links.new(shadow_or_reflect_or_refract.inputs[1], refracted.outputs[0])
381 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
382 node_tree.links.new(mix_shader.inputs[0], shadow_or_reflect_or_refract.outputs[0])
383 node_tree.links.new(mix_shader.inputs[1], diffuse_shader.outputs[0])
384 node_tree.links.new(mix_shader.inputs[2], emission_shader.outputs[0])
386 node_tree.links.new(output_node.inputs[0], mix_shader.outputs[0])
388 auto_align_nodes(node_tree)
390 group_node = dest_node_tree.nodes.new("ShaderNodeGroup")
391 group_node.node_tree = node_tree
393 return group_node
396 # -----------------------------------------------------------------------------
397 # Corner Pin Driver Helpers
399 @bpy.app.handlers.persistent
400 def check_drivers(*args, **kwargs):
401 """Check if watched objects in a scene have changed and trigger compositor update
403 This is part of a hack to ensure the compositor updates
404 itself when the objects used for drivers change.
406 It only triggers if transformation matricies change to avoid
407 a cyclic loop of updates.
409 if not watched_objects:
410 # if there is nothing to watch, don't bother running this
411 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
412 return
414 update = False
415 for name, matrix in list(watched_objects.items()):
416 try:
417 obj = bpy.data.objects[name]
418 except KeyError:
419 # The user must have removed this object
420 del watched_objects[name]
421 else:
422 new_matrix = tuple(map(tuple, obj.matrix_world)).__hash__()
423 if new_matrix != matrix:
424 watched_objects[name] = new_matrix
425 update = True
427 if update:
428 # Trick to re-evaluate drivers
429 bpy.context.scene.frame_current = bpy.context.scene.frame_current
432 def register_watched_object(obj):
433 """Register an object to be monitored for transformation changes"""
434 name = obj.name
436 # known object? -> we're done
437 if name in watched_objects:
438 return
440 if not watched_objects:
441 # make sure check_drivers is active
442 bpy.app.handlers.depsgraph_update_post.append(check_drivers)
444 watched_objects[name] = None
447 def find_plane_corner(object_name, x, y, axis, camera=None, *args, **kwargs):
448 """Find the location in camera space of a plane's corner"""
449 if args or kwargs:
450 # I've added args / kwargs as a compatibility measure with future versions
451 warnings.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
453 plane = bpy.data.objects[object_name]
455 # Passing in camera doesn't work before 2.78, so we use the current one
456 camera = camera or bpy.context.scene.camera
458 # Hack to ensure compositor updates on future changes
459 register_watched_object(camera)
460 register_watched_object(plane)
462 scale = plane.scale * 2.0
463 v = plane.dimensions.copy()
464 v.x *= x / scale.x
465 v.y *= y / scale.y
466 v = plane.matrix_world @ v
468 camera_vertex = world_to_camera_view(
469 bpy.context.scene, camera, v)
471 return camera_vertex[axis]
474 @bpy.app.handlers.persistent
475 def register_driver(*args, **kwargs):
476 """Register the find_plane_corner function for use with drivers"""
477 bpy.app.driver_namespace['import_image__find_plane_corner'] = find_plane_corner
480 # -----------------------------------------------------------------------------
481 # Compositing Helpers
483 def group_in_frame(node_tree, name, nodes):
484 frame_node = node_tree.nodes.new("NodeFrame")
485 frame_node.label = name
486 frame_node.name = name + "_frame"
488 min_pos = Vector(nodes[0].location)
489 max_pos = min_pos.copy()
491 for node in nodes:
492 top_left = node.location
493 bottom_right = top_left + Vector((node.width, -node.height))
495 for i in (0, 1):
496 min_pos[i] = min(min_pos[i], top_left[i], bottom_right[i])
497 max_pos[i] = max(max_pos[i], top_left[i], bottom_right[i])
499 node.parent = frame_node
501 frame_node.width = max_pos[0] - min_pos[0] + 50
502 frame_node.height = max(max_pos[1] - min_pos[1] + 50, 450)
503 frame_node.shrink = True
505 return frame_node
508 def position_frame_bottom_left(node_tree, frame_node):
509 newpos = Vector((100000, 100000)) # start reasonably far top / right
511 # Align with the furthest left
512 for node in node_tree.nodes.values():
513 if node != frame_node and node.parent != frame_node:
514 newpos.x = min(newpos.x, node.location.x + 30)
516 # As high as we can get without overlapping anything to the right
517 for node in node_tree.nodes.values():
518 if node != frame_node and not node.parent:
519 if node.location.x < newpos.x + frame_node.width:
520 print("Below", node.name, node.location, node.height, node.dimensions)
521 newpos.y = min(newpos.y, node.location.y - max(node.dimensions.y, node.height) - 20)
523 frame_node.location = newpos
526 def setup_compositing(context, plane, img_spec):
527 # Node Groups only work with "new" dependency graph and even
528 # then it has some problems with not updating the first time
529 # So instead this groups with a node frame, which works reliably
531 scene = context.scene
532 scene.use_nodes = True
533 node_tree = scene.node_tree
534 name = plane.name
536 image_node = node_tree.nodes.new("CompositorNodeImage")
537 image_node.name = name + "_image"
538 image_node.image = img_spec.image
539 image_node.location = Vector((0, 0))
540 image_node.frame_start = img_spec.frame_start
541 image_node.frame_offset = img_spec.frame_offset
542 image_node.frame_duration = img_spec.frame_duration
544 scale_node = node_tree.nodes.new("CompositorNodeScale")
545 scale_node.name = name + "_scale"
546 scale_node.space = 'RENDER_SIZE'
547 scale_node.location = image_node.location + \
548 Vector((image_node.width + 20, 0))
549 scale_node.show_options = False
551 cornerpin_node = node_tree.nodes.new("CompositorNodeCornerPin")
552 cornerpin_node.name = name + "_cornerpin"
553 cornerpin_node.location = scale_node.location + \
554 Vector((0, -scale_node.height))
556 node_tree.links.new(scale_node.inputs[0], image_node.outputs[0])
557 node_tree.links.new(cornerpin_node.inputs[0], scale_node.outputs[0])
559 # Put all the nodes in a frame for organization
560 frame_node = group_in_frame(
561 node_tree, name,
562 (image_node, scale_node, cornerpin_node)
565 # Position frame at bottom / left
566 position_frame_bottom_left(node_tree, frame_node)
568 # Configure Drivers
569 for corner in cornerpin_node.inputs[1:]:
570 id = corner.identifier
571 x = -1 if 'Left' in id else 1
572 y = -1 if 'Lower' in id else 1
573 drivers = corner.driver_add('default_value')
574 for i, axis_fcurve in enumerate(drivers):
575 driver = axis_fcurve.driver
576 # Always use the current camera
577 add_driver_prop(driver, 'camera', 'SCENE', scene, 'camera')
578 # Track camera location to ensure Deps Graph triggers (not used in the call)
579 add_driver_prop(driver, 'cam_loc_x', 'OBJECT', scene.camera, 'location[0]')
580 # Don't break if the name changes
581 add_driver_prop(driver, 'name', 'OBJECT', plane, 'name')
582 driver.expression = "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
583 repr(plane.name),
584 x, y, i
586 driver.type = 'SCRIPTED'
587 driver.is_valid = True
588 axis_fcurve.is_valid = True
589 driver.expression = "%s" % driver.expression
591 context.view_layer.update()
594 # -----------------------------------------------------------------------------
595 # Operator
597 class IMPORT_IMAGE_OT_to_plane(Operator, AddObjectHelper):
598 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
600 bl_idname = "import_image.to_plane"
601 bl_label = "Import Images as Planes"
602 bl_options = {'REGISTER', 'PRESET', 'UNDO'}
604 # ----------------------
605 # File dialog properties
606 files: CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
608 directory: StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
610 filter_image: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
611 filter_movie: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
612 filter_folder: BoolProperty(default=True, options={'HIDDEN', 'SKIP_SAVE'})
614 # ----------------------
615 # Properties - Importing
616 force_reload: BoolProperty(
617 name="Force Reload", default=False,
618 description="Force reloading of the image if already opened elsewhere in Blender"
621 image_sequence: BoolProperty(
622 name="Animate Image Sequences", default=False,
623 description="Import sequentially numbered images as an animated "
624 "image sequence instead of separate planes"
627 # -------------------------------------
628 # Properties - Position and Orientation
629 axis_id_to_vector = {
630 'X+': Vector(( 1, 0, 0)),
631 'Y+': Vector(( 0, 1, 0)),
632 'Z+': Vector(( 0, 0, 1)),
633 'X-': Vector((-1, 0, 0)),
634 'Y-': Vector(( 0, -1, 0)),
635 'Z-': Vector(( 0, 0, -1)),
638 offset: BoolProperty(name="Offset Planes", default=True, description="Offset Planes From Each Other")
640 OFFSET_MODES = (
641 ('X+', "X+", "Side by Side to the Left"),
642 ('Y+', "Y+", "Side by Side, Downward"),
643 ('Z+', "Z+", "Stacked Above"),
644 ('X-', "X-", "Side by Side to the Right"),
645 ('Y-', "Y-", "Side by Side, Upward"),
646 ('Z-', "Z-", "Stacked Below"),
648 offset_axis: EnumProperty(
649 name="Orientation", default='X+', items=OFFSET_MODES,
650 description="How planes are oriented relative to each others' local axis"
653 offset_amount: FloatProperty(
654 name="Offset", soft_min=0, default=0.1, description="Space between planes",
655 subtype='DISTANCE', unit='LENGTH'
658 AXIS_MODES = (
659 ('X+', "X+", "Facing Positive X"),
660 ('Y+', "Y+", "Facing Positive Y"),
661 ('Z+', "Z+ (Up)", "Facing Positive Z"),
662 ('X-', "X-", "Facing Negative X"),
663 ('Y-', "Y-", "Facing Negative Y"),
664 ('Z-', "Z- (Down)", "Facing Negative Z"),
665 ('CAM', "Face Camera", "Facing Camera"),
666 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
668 align_axis: EnumProperty(
669 name="Align", default='CAM_AX', items=AXIS_MODES,
670 description="How to align the planes"
672 # prev_align_axis is used only by update_size_model
673 prev_align_axis: EnumProperty(
674 items=AXIS_MODES + (('NONE', '', ''),), default='NONE', options={'HIDDEN', 'SKIP_SAVE'})
675 align_track: BoolProperty(
676 name="Track Camera", default=False, description="Always face the camera"
679 # -----------------
680 # Properties - Size
681 def update_size_mode(self, context):
682 """If sizing relative to the camera, always face the camera"""
683 if self.size_mode == 'CAMERA':
684 self.prev_align_axis = self.align_axis
685 self.align_axis = 'CAM'
686 else:
687 # if a different alignment was set revert to that when
688 # size mode is changed
689 if self.prev_align_axis != 'NONE':
690 self.align_axis = self.prev_align_axis
691 self._prev_align_axis = 'NONE'
693 SIZE_MODES = (
694 ('ABSOLUTE', "Absolute", "Use absolute size"),
695 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
696 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
697 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
699 size_mode: EnumProperty(
700 name="Size Mode", default='ABSOLUTE', items=SIZE_MODES,
701 update=update_size_mode,
702 description="How the size of the plane is computed")
704 FILL_MODES = (
705 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
706 ('FIT', "Fit", "Fit entire image within the camera frame"),
708 fill_mode: EnumProperty(name="Scale", default='FILL', items=FILL_MODES,
709 description="How large in the camera frame is the plane")
711 height: FloatProperty(name="Height", description="Height of the created plane",
712 default=1.0, min=0.001, soft_min=0.001, subtype='DISTANCE', unit='LENGTH')
714 factor: FloatProperty(name="Definition", min=1.0, default=600.0,
715 description="Number of pixels per inch or Blender Unit")
717 # ------------------------------
718 # Properties - Material / Shader
719 SHADERS = (
720 ('PRINCIPLED',"Principled","Principled Shader"),
721 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
722 ('EMISSION', "Emit", "Emission Shader"),
724 shader: EnumProperty(name="Shader", items=SHADERS, default='PRINCIPLED', description="Node shader to use")
726 emit_strength: FloatProperty(
727 name="Strength", min=0.0, default=1.0, soft_max=10.0,
728 step=100, description="Brightness of Emission Texture")
730 use_transparency: BoolProperty(
731 name="Use Alpha", default=True,
732 description="Use alpha channel for transparency")
734 BLEND_METHODS = (
735 ('BLEND',"Blend","Render polygon transparent, depending on alpha channel of the texture"),
736 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
737 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
738 ('OPAQUE', "Opaque","Render surface without transparency"),
740 blend_method: EnumProperty(
741 name="Blend Mode", items=BLEND_METHODS, default='BLEND',
742 description="Blend Mode for Transparent Faces", translation_context=i18n_contexts.id_material)
744 SHADOW_METHODS = (
745 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
746 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
747 ('OPAQUE',"Opaque","Material will cast shadows without transparency"),
748 ('NONE',"None","Material will cast no shadow"),
750 shadow_method: EnumProperty(
751 name="Shadow Mode", items=SHADOW_METHODS, default='CLIP',
752 description="Shadow mapping method", translation_context=i18n_contexts.id_material)
754 use_backface_culling: BoolProperty(
755 name="Backface Culling", default=False,
756 description="Use back face culling to hide the back side of faces")
758 show_transparent_back: BoolProperty(
759 name="Show Backface", default=True,
760 description="Render multiple transparent layers (may introduce transparency sorting problems)")
762 overwrite_material: BoolProperty(
763 name="Overwrite Material", default=True,
764 description="Overwrite existing Material (based on material name)")
766 compositing_nodes: BoolProperty(
767 name="Setup Corner Pin", default=False,
768 description="Build Compositor Nodes to reference this image "
769 "without re-rendering")
771 # ------------------
772 # Properties - Image
773 INTERPOLATION_MODES = (
774 ('Linear', "Linear", "Linear interpolation"),
775 ('Closest', "Closest", "No interpolation (sample closest texel)"),
776 ('Cubic', "Cubic", "Cubic interpolation"),
777 ('Smart', "Smart", "Bicubic when magnifying, else bilinear (OSL only)"),
779 interpolation: EnumProperty(name="Interpolation", items=INTERPOLATION_MODES, default='Linear', description="Texture interpolation")
781 EXTENSION_MODES = (
782 ('CLIP', "Clip", "Clip to image size and set exterior pixels as transparent"),
783 ('EXTEND', "Extend", "Extend by repeating edge pixels of the image"),
784 ('REPEAT', "Repeat", "Cause the image to repeat horizontally and vertically"),
786 extension: EnumProperty(name="Extension", items=EXTENSION_MODES, default='CLIP', description="How the image is extrapolated past its original bounds")
788 t = bpy.types.Image.bl_rna.properties["alpha_mode"]
789 alpha_mode_items = tuple((e.identifier, e.name, e.description) for e in t.enum_items)
790 alpha_mode: EnumProperty(
791 name=t.name, items=alpha_mode_items, default=t.default,
792 description=t.description)
794 t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"]
795 use_auto_refresh: BoolProperty(name=t.name, default=True, description=t.description)
797 relative: BoolProperty(name="Relative Paths", default=True, description="Use relative file paths")
799 # -------
800 # Draw UI
801 def draw_import_config(self, context):
802 # --- Import Options --- #
803 layout = self.layout
804 box = layout.box()
806 box.label(text="Import Options:", icon='IMPORT')
807 row = box.row()
808 row.active = bpy.data.is_saved
809 row.prop(self, "relative")
811 box.prop(self, "force_reload")
812 box.prop(self, "image_sequence")
814 def draw_material_config(self, context):
815 # --- Material / Rendering Properties --- #
816 layout = self.layout
817 box = layout.box()
819 box.label(text="Compositing Nodes:", icon='RENDERLAYERS')
820 box.prop(self, "compositing_nodes")
821 layout = self.layout
822 box = layout.box()
823 box.label(text="Material Settings:", icon='MATERIAL')
825 box.label(text="Material Type")
826 row = box.row()
827 row.prop(self, 'shader', expand=True)
828 if self.shader == 'EMISSION':
829 box.prop(self, "emit_strength")
831 box.label(text="Blend Mode")
832 row = box.row()
833 row.prop(self, 'blend_method', expand=True)
834 if self.use_transparency and self.alpha_mode != "NONE" and self.blend_method == "OPAQUE":
835 box.label(text="'Opaque' does not support alpha", icon="ERROR")
836 if self.blend_method == 'BLEND':
837 row = box.row()
838 row.prop(self, "show_transparent_back")
840 box.label(text="Shadow Mode")
841 row = box.row()
842 row.prop(self, 'shadow_method', expand=True)
844 row = box.row()
845 row.prop(self, "use_backface_culling")
847 engine = context.scene.render.engine
848 if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
849 box.label(text=tip_("%s is not supported") % engine, icon='ERROR')
851 box.prop(self, "overwrite_material")
852 layout = self.layout
853 box = layout.box()
854 box.label(text="Texture Settings:", icon='TEXTURE')
855 box.label(text="Interpolation")
856 row = box.row()
857 row.prop(self, 'interpolation', expand=True)
858 box.label(text="Extension")
859 row = box.row()
860 row.prop(self, 'extension', expand=True)
861 row = box.row()
862 row.prop(self, "use_transparency")
863 if self.use_transparency:
864 sub = row.row()
865 sub.prop(self, "alpha_mode", text="")
866 row = box.row()
867 row.prop(self, "use_auto_refresh")
869 def draw_spatial_config(self, context):
870 # --- Spatial Properties: Position, Size and Orientation --- #
871 layout = self.layout
872 box = layout.box()
874 box.label(text="Position:", icon='SNAP_GRID')
875 box.prop(self, "offset")
876 col = box.column()
877 row = col.row()
878 row.prop(self, "offset_axis", expand=True)
879 row = col.row()
880 row.prop(self, "offset_amount")
881 col.enabled = self.offset
883 box.label(text="Plane dimensions:", icon='ARROW_LEFTRIGHT')
884 row = box.row()
885 row.prop(self, "size_mode", expand=True)
886 if self.size_mode == 'ABSOLUTE':
887 box.prop(self, "height")
888 elif self.size_mode == 'CAMERA':
889 row = box.row()
890 row.prop(self, "fill_mode", expand=True)
891 else:
892 box.prop(self, "factor")
894 box.label(text="Orientation:")
895 row = box.row()
896 row.enabled = 'CAM' not in self.size_mode
897 row.prop(self, "align_axis")
898 row = box.row()
899 row.enabled = 'CAM' in self.align_axis
900 row.alignment = 'RIGHT'
901 row.prop(self, "align_track")
903 def draw(self, context):
905 # Draw configuration sections
906 self.draw_import_config(context)
907 self.draw_material_config(context)
908 self.draw_spatial_config(context)
910 # -------------------------------------------------------------------------
911 # Core functionality
912 def invoke(self, context, event):
913 engine = context.scene.render.engine
914 if engine not in {'CYCLES', 'BLENDER_EEVEE'}:
915 if engine != 'BLENDER_WORKBENCH':
916 self.report({'ERROR'}, tip_("Cannot generate materials for unknown %s render engine") % engine)
917 return {'CANCELLED'}
918 else:
919 self.report({'WARNING'},
920 tip_("Generating Cycles/EEVEE compatible material, but won't be visible with %s engine") % engine)
922 # Open file browser
923 context.window_manager.fileselect_add(self)
924 return {'RUNNING_MODAL'}
926 def execute(self, context):
927 if not bpy.data.is_saved:
928 self.relative = False
930 # this won't work in edit mode
931 editmode = context.preferences.edit.use_enter_edit_mode
932 context.preferences.edit.use_enter_edit_mode = False
933 if context.active_object and context.active_object.mode != 'OBJECT':
934 bpy.ops.object.mode_set(mode='OBJECT')
936 ret_code = self.import_images(context)
938 context.preferences.edit.use_enter_edit_mode = editmode
940 return ret_code
942 def import_images(self, context):
944 # load images / sequences
945 images = tuple(load_images(
946 (fn.name for fn in self.files),
947 self.directory,
948 force_reload=self.force_reload,
949 find_sequences=self.image_sequence
952 if not images:
953 self.report({'WARNING'}, "Please select at least an image.")
954 return {'CANCELLED'}
956 # Create individual planes
957 planes = [self.single_image_spec_to_plane(context, img_spec) for img_spec in images]
959 context.view_layer.update()
961 # Align planes relative to each other
962 if self.offset:
963 offset_axis = self.axis_id_to_vector[self.offset_axis]
964 offset_planes(planes, self.offset_amount, offset_axis)
966 if self.size_mode == 'CAMERA' and offset_axis.z:
967 for plane in planes:
968 x, y = compute_camera_size(
969 context, plane.location,
970 self.fill_mode, plane.dimensions.x / plane.dimensions.y)
971 plane.dimensions = x, y, 0.0
973 # setup new selection
974 for plane in planes:
975 plane.select_set(True)
977 # all done!
978 self.report({'INFO'}, tip_("Added {} Image Plane(s)").format(len(planes)))
979 return {'FINISHED'}
981 # operate on a single image
982 def single_image_spec_to_plane(self, context, img_spec):
984 # Configure image
985 self.apply_image_options(img_spec.image)
987 # Configure material
988 engine = context.scene.render.engine
989 if engine in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
990 material = self.create_cycles_material(context, img_spec)
992 # Create and position plane object
993 plane = self.create_image_plane(context, material.name, img_spec)
995 # Assign Material
996 plane.data.materials.append(material)
998 # If applicable, setup Corner Pin node
999 if self.compositing_nodes:
1000 setup_compositing(context, plane, img_spec)
1002 return plane
1004 def apply_image_options(self, image):
1005 if self.use_transparency == False:
1006 image.alpha_mode = 'NONE'
1007 else:
1008 image.alpha_mode = self.alpha_mode
1010 if self.relative:
1011 try: # can't always find the relative path (between drive letters on windows)
1012 image.filepath = bpy.path.relpath(image.filepath)
1013 except ValueError:
1014 pass
1016 def apply_texture_options(self, texture, img_spec):
1017 # Shared by both Cycles and Blender Internal
1018 image_user = texture.image_user
1019 image_user.use_auto_refresh = self.use_auto_refresh
1020 image_user.frame_start = img_spec.frame_start
1021 image_user.frame_offset = img_spec.frame_offset
1022 image_user.frame_duration = img_spec.frame_duration
1024 # Image sequences need auto refresh to display reliably
1025 if img_spec.image.source == 'SEQUENCE':
1026 image_user.use_auto_refresh = True
1028 def apply_material_options(self, material, slot):
1029 shader = self.shader
1031 if self.use_transparency:
1032 material.alpha = 0.0
1033 material.specular_alpha = 0.0
1034 slot.use_map_alpha = True
1035 else:
1036 material.alpha = 1.0
1037 material.specular_alpha = 1.0
1038 slot.use_map_alpha = False
1040 material.specular_intensity = 0
1041 material.diffuse_intensity = 1.0
1042 material.use_transparency = self.use_transparency
1043 material.transparency_method = 'Z_TRANSPARENCY'
1044 material.use_shadeless = (shader == 'SHADELESS')
1045 material.use_transparent_shadows = (shader == 'DIFFUSE')
1046 material.emit = self.emit_strength if shader == 'EMISSION' else 0.0
1048 # -------------------------------------------------------------------------
1049 # Cycles/Eevee
1050 def create_cycles_texnode(self, context, node_tree, img_spec):
1051 tex_image = node_tree.nodes.new('ShaderNodeTexImage')
1052 tex_image.image = img_spec.image
1053 tex_image.show_texture = True
1054 tex_image.interpolation = self.interpolation
1055 tex_image.extension = self.extension
1056 self.apply_texture_options(tex_image, img_spec)
1057 return tex_image
1059 def create_cycles_material(self, context, img_spec):
1060 image = img_spec.image
1061 name_compat = bpy.path.display_name_from_filepath(image.filepath)
1062 material = None
1063 if self.overwrite_material:
1064 for mat in bpy.data.materials:
1065 if mat.name == name_compat:
1066 material = mat
1067 if not material:
1068 material = bpy.data.materials.new(name=name_compat)
1070 material.use_nodes = True
1072 material.blend_method = self.blend_method
1073 material.shadow_method = self.shadow_method
1075 material.use_backface_culling = self.use_backface_culling
1076 material.show_transparent_back = self.show_transparent_back
1078 node_tree = material.node_tree
1079 out_node = clean_node_tree(node_tree)
1081 tex_image = self.create_cycles_texnode(context, node_tree, img_spec)
1083 if self.shader == 'PRINCIPLED':
1084 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1085 elif self.shader == 'SHADELESS':
1086 core_shader = get_shadeless_node(node_tree)
1087 elif self.shader == 'EMISSION':
1088 core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled')
1089 core_shader.inputs['Emission Strength'].default_value = self.emit_strength
1090 core_shader.inputs['Base Color'].default_value = (0.0, 0.0, 0.0, 1.0)
1091 core_shader.inputs['Specular IOR Level'].default_value = 0.0
1093 # Connect color from texture
1094 if self.shader in {'PRINCIPLED', 'SHADELESS'}:
1095 node_tree.links.new(core_shader.inputs[0], tex_image.outputs['Color'])
1096 elif self.shader == 'EMISSION':
1097 node_tree.links.new(core_shader.inputs['Emission Color'], tex_image.outputs['Color'])
1099 if self.use_transparency:
1100 if self.shader in {'PRINCIPLED', 'EMISSION'}:
1101 node_tree.links.new(core_shader.inputs['Alpha'], tex_image.outputs['Alpha'])
1102 else:
1103 bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent')
1105 mix_shader = node_tree.nodes.new('ShaderNodeMixShader')
1106 node_tree.links.new(mix_shader.inputs['Fac'], tex_image.outputs['Alpha'])
1107 node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs['BSDF'])
1108 node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0])
1109 core_shader = mix_shader
1111 node_tree.links.new(out_node.inputs['Surface'], core_shader.outputs[0])
1113 auto_align_nodes(node_tree)
1114 return material
1116 # -------------------------------------------------------------------------
1117 # Geometry Creation
1118 def create_image_plane(self, context, name, img_spec):
1120 width, height = self.compute_plane_size(context, img_spec)
1122 # Create new mesh
1123 bpy.ops.mesh.primitive_plane_add('INVOKE_REGION_WIN')
1124 plane = context.active_object
1125 # Why does mesh.primitive_plane_add leave the object in edit mode???
1126 if plane.mode != 'OBJECT':
1127 bpy.ops.object.mode_set(mode='OBJECT')
1128 plane.dimensions = width, height, 0.0
1129 plane.data.name = plane.name = name
1130 bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
1132 # If sizing for camera, also insert into the camera's field of view
1133 if self.size_mode == 'CAMERA':
1134 offset_axis = self.axis_id_to_vector[self.offset_axis]
1135 translate_axis = [0 if offset_axis[i] else 1 for i in (0, 1)]
1136 center_in_camera(context.scene, context.scene.camera, plane, translate_axis)
1138 self.align_plane(context, plane)
1140 return plane
1142 def compute_plane_size(self, context, img_spec):
1143 """Given the image size in pixels and location, determine size of plane"""
1144 px, py = img_spec.size
1146 # can't load data
1147 if px == 0 or py == 0:
1148 px = py = 1
1150 if self.size_mode == 'ABSOLUTE':
1151 y = self.height
1152 x = px / py * y
1154 elif self.size_mode == 'CAMERA':
1155 x, y = compute_camera_size(
1156 context, context.scene.cursor.location,
1157 self.fill_mode, px / py
1160 elif self.size_mode == 'DPI':
1161 fact = 1 / self.factor / context.scene.unit_settings.scale_length * 0.0254
1162 x = px * fact
1163 y = py * fact
1165 else: # elif self.size_mode == 'DPBU'
1166 fact = 1 / self.factor
1167 x = px * fact
1168 y = py * fact
1170 return x, y
1172 def align_plane(self, context, plane):
1173 """Pick an axis and align the plane to it"""
1174 if 'CAM' in self.align_axis:
1175 # Camera-aligned
1176 camera = context.scene.camera
1177 if (camera):
1178 # Find the axis that best corresponds to the camera's view direction
1179 axis = camera.matrix_world @ \
1180 Vector((0, 0, 1)) - camera.matrix_world.col[3].xyz
1181 # pick the axis with the greatest magnitude
1182 mag = max(map(abs, axis))
1183 # And use that axis & direction
1184 axis = Vector([
1185 n / mag if abs(n) == mag else 0.0
1186 for n in axis
1188 else:
1189 # No camera? Just face Z axis
1190 axis = Vector((0, 0, 1))
1191 self.align_axis = 'Z+'
1192 else:
1193 # Axis-aligned
1194 axis = self.axis_id_to_vector[self.align_axis]
1196 # rotate accordingly for x/y axiis
1197 if not axis.z:
1198 plane.rotation_euler.x = pi / 2
1200 if axis.y > 0:
1201 plane.rotation_euler.z = pi
1202 elif axis.y < 0:
1203 plane.rotation_euler.z = 0
1204 elif axis.x > 0:
1205 plane.rotation_euler.z = pi / 2
1206 elif axis.x < 0:
1207 plane.rotation_euler.z = -pi / 2
1209 # or flip 180 degrees for negative z
1210 elif axis.z < 0:
1211 plane.rotation_euler.y = pi
1213 if self.align_axis == 'CAM':
1214 constraint = plane.constraints.new('COPY_ROTATION')
1215 constraint.target = camera
1216 constraint.use_x = constraint.use_y = constraint.use_z = True
1217 if not self.align_track:
1218 bpy.ops.object.visual_transform_apply()
1219 plane.constraints.clear()
1221 if self.align_axis == 'CAM_AX' and self.align_track:
1222 constraint = plane.constraints.new('LOCKED_TRACK')
1223 constraint.target = camera
1224 constraint.track_axis = 'TRACK_Z'
1225 constraint.lock_axis = 'LOCK_Y'
1228 # -----------------------------------------------------------------------------
1229 # Register
1231 def import_images_button(self, context):
1232 self.layout.operator(IMPORT_IMAGE_OT_to_plane.bl_idname, text="Images as Planes", icon='TEXTURE')
1235 classes = (
1236 IMPORT_IMAGE_OT_to_plane,
1240 def register():
1241 for cls in classes:
1242 bpy.utils.register_class(cls)
1244 bpy.types.TOPBAR_MT_file_import.append(import_images_button)
1245 bpy.types.VIEW3D_MT_image_add.append(import_images_button)
1247 bpy.app.handlers.load_post.append(register_driver)
1248 register_driver()
1251 def unregister():
1252 bpy.types.TOPBAR_MT_file_import.remove(import_images_button)
1253 bpy.types.VIEW3D_MT_image_add.remove(import_images_button)
1255 # This will only exist if drivers are active
1256 if check_drivers in bpy.app.handlers.depsgraph_update_post:
1257 bpy.app.handlers.depsgraph_update_post.remove(check_drivers)
1259 bpy.app.handlers.load_post.remove(register_driver)
1260 del bpy.app.driver_namespace['import_image__find_plane_corner']
1262 for cls in classes:
1263 bpy.utils.unregister_class(cls)
1266 if __name__ == "__main__":
1267 # Run simple doc tests
1268 import doctest
1269 doctest.testmod()
1271 unregister()
1272 register()