1 # SPDX-FileCopyrightText: 2010-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 "name": "Import Images as Planes",
7 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc), mrbimax",
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.",
14 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/images_as_planes.html",
15 "support": 'OFFICIAL',
16 "category": "Import-Export",
22 from itertools
import count
, repeat
23 from collections
import namedtuple
27 from bpy
.types
import Operator
28 from bpy
.app
.translations
import (
30 contexts
as i18n_contexts
32 from mathutils
import Vector
34 from bpy
.props
import (
42 from bpy_extras
.object_utils
import (
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 # -----------------------------------------------------------------------------
58 def add_driver_prop(driver
, name
, type, id, path
):
59 """Configure a new driver variable."""
60 dv
= driver
.variables
.new()
62 dv
.type = 'SINGLE_PROP'
63 target
= dv
.targets
[0]
66 target
.data_path
= path
69 # -----------------------------------------------------------------------------
72 ImageSpec
= namedtuple(
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",
90 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
93 files
= iter(sorted(files
))
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
107 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
112 # did it only change by one?
113 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
115 # We expect this to increment
125 # No continuation -> spit out what we found and reset counters
128 yield prev_file
, matches
[segment
], length
130 yield prev_file
, 1, 1
133 matches
= new_matches
134 pattern
= new_pattern
140 yield prev_file
, matches
[segment
], length
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
)
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
))):
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':
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.
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
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
)
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
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
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 # -----------------------------------------------------------------------------
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!).
263 for socket
in node
.inputs
:
265 for link
in input_links
:
268 # Node already treated!
270 elif link
.to_socket
== socket
:
271 sorted_nodes
.append(nd
)
274 input_links
-= done_links
278 def auto_align_nodes(node_tree
):
279 """Given a shader node tree, arrange nodes neatly relative to the output node."""
282 nodes
= node_tree
.nodes
283 links
= node_tree
.links
286 if node
.type == 'OUTPUT_MATERIAL' or node
.type == 'GROUP_OUTPUT':
290 else: # Just in case there is no output
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
))
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':
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"""
321 node_tree
= bpy
.data
.node_groups
['IAP_SHADELESS']
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
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
)
415 for name
, matrix
in list(watched_objects
.items()):
417 obj
= bpy
.data
.objects
[name
]
419 # The user must have removed this object
420 del watched_objects
[name
]
422 new_matrix
= tuple(map(tuple, obj
.matrix_world
)).__hash
__()
423 if new_matrix
!= matrix
:
424 watched_objects
[name
] = new_matrix
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"""
436 # known object? -> we're done
437 if name
in watched_objects
:
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"""
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()
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()
492 top_left
= node
.location
493 bottom_right
= top_left
+ Vector((node
.width
, -node
.height
))
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
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
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(
562 (image_node
, scale_node
, cornerpin_node
)
565 # Position frame at bottom / left
566 position_frame_bottom_left(node_tree
, frame_node
)
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)" % (
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 # -----------------------------------------------------------------------------
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")
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'
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"
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'
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'
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")
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
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")
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
)
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")
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")
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")
801 def draw_import_config(self
, context
):
802 # --- Import Options --- #
806 box
.label(text
="Import Options:", icon
='IMPORT')
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 --- #
819 box
.label(text
="Compositing Nodes:", icon
='RENDERLAYERS')
820 box
.prop(self
, "compositing_nodes")
823 box
.label(text
="Material Settings:", icon
='MATERIAL')
825 box
.label(text
="Material Type")
827 row
.prop(self
, 'shader', expand
=True)
828 if self
.shader
== 'EMISSION':
829 box
.prop(self
, "emit_strength")
831 box
.label(text
="Blend Mode")
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':
838 row
.prop(self
, "show_transparent_back")
840 box
.label(text
="Shadow Mode")
842 row
.prop(self
, 'shadow_method', expand
=True)
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")
854 box
.label(text
="Texture Settings:", icon
='TEXTURE')
855 box
.label(text
="Interpolation")
857 row
.prop(self
, 'interpolation', expand
=True)
858 box
.label(text
="Extension")
860 row
.prop(self
, 'extension', expand
=True)
862 row
.prop(self
, "use_transparency")
863 if self
.use_transparency
:
865 sub
.prop(self
, "alpha_mode", text
="")
867 row
.prop(self
, "use_auto_refresh")
869 def draw_spatial_config(self
, context
):
870 # --- Spatial Properties: Position, Size and Orientation --- #
874 box
.label(text
="Position:", icon
='SNAP_GRID')
875 box
.prop(self
, "offset")
878 row
.prop(self
, "offset_axis", expand
=True)
880 row
.prop(self
, "offset_amount")
881 col
.enabled
= self
.offset
883 box
.label(text
="Plane dimensions:", icon
='ARROW_LEFTRIGHT')
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':
890 row
.prop(self
, "fill_mode", expand
=True)
892 box
.prop(self
, "factor")
894 box
.label(text
="Orientation:")
896 row
.enabled
= 'CAM' not in self
.size_mode
897 row
.prop(self
, "align_axis")
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 # -------------------------------------------------------------------------
912 def invoke(self
, context
, event
):
913 engine
= context
.scene
.render
.engine
914 if engine
not in {'CYCLES', 'BLENDER_EEVEE','BLENDER_EEVEE_NEXT'}:
915 if engine
!= 'BLENDER_WORKBENCH':
916 self
.report({'ERROR'}, tip_("Cannot generate materials for unknown %s render engine") % engine
)
919 self
.report({'WARNING'},
920 tip_("Generating Cycles/EEVEE compatible material, but won't be visible with %s engine") % engine
)
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
942 def import_images(self
, context
):
944 # load images / sequences
945 images
= tuple(load_images(
946 (fn
.name
for fn
in self
.files
),
948 force_reload
=self
.force_reload
,
949 find_sequences
=self
.image_sequence
953 self
.report({'WARNING'}, "Please select at least one image")
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
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
:
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
975 plane
.select_set(True)
978 self
.report({'INFO'}, tip_("Added {} Image Plane(s)").format(len(planes
)))
981 # operate on a single image
982 def single_image_spec_to_plane(self
, context
, img_spec
):
985 self
.apply_image_options(img_spec
.image
)
988 engine
= context
.scene
.render
.engine
989 if engine
in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT', '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
)
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
)
1004 def apply_image_options(self
, image
):
1005 if self
.use_transparency
== False:
1006 image
.alpha_mode
= 'NONE'
1008 image
.alpha_mode
= self
.alpha_mode
1011 try: # can't always find the relative path (between drive letters on windows)
1012 image
.filepath
= bpy
.path
.relpath(image
.filepath
)
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
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 # -------------------------------------------------------------------------
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
)
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
)
1063 if self
.overwrite_material
:
1064 for mat
in bpy
.data
.materials
:
1065 if mat
.name
== name_compat
:
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'])
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
)
1116 # -------------------------------------------------------------------------
1118 def create_image_plane(self
, context
, name
, img_spec
):
1120 width
, height
= self
.compute_plane_size(context
, img_spec
)
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
)
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
1147 if px
== 0 or py
== 0:
1150 if self
.size_mode
== 'ABSOLUTE':
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
1165 else: # elif self.size_mode == 'DPBU'
1166 fact
= 1 / self
.factor
1172 def align_plane(self
, context
, plane
):
1173 """Pick an axis and align the plane to it"""
1174 if 'CAM' in self
.align_axis
:
1176 camera
= context
.scene
.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
1185 n
/ mag
if abs(n
) == mag
else 0.0
1189 # No camera? Just face Z axis
1190 axis
= Vector((0, 0, 1))
1191 self
.align_axis
= 'Z+'
1194 axis
= self
.axis_id_to_vector
[self
.align_axis
]
1196 # rotate accordingly for x/y axiis
1198 plane
.rotation_euler
.x
= pi
/ 2
1201 plane
.rotation_euler
.z
= pi
1203 plane
.rotation_euler
.z
= 0
1205 plane
.rotation_euler
.z
= pi
/ 2
1207 plane
.rotation_euler
.z
= -pi
/ 2
1209 # or flip 180 degrees for negative z
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 # -----------------------------------------------------------------------------
1231 def import_images_button(self
, context
):
1232 self
.layout
.operator(IMPORT_IMAGE_OT_to_plane
.bl_idname
, text
="Images as Planes", icon
='TEXTURE')
1236 IMPORT_IMAGE_OT_to_plane
,
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
)
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']
1263 bpy
.utils
.unregister_class(cls
)
1266 if __name__
== "__main__":
1267 # Run simple doc tests