1 # SPDX-License-Identifier: GPL-2.0-or-later
6 "name": "Import Images as Planes",
7 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc)",
10 "location": "File > Import > Images as Planes or Add > Mesh > 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 mathutils
import Vector
30 from bpy
.props
import (
38 from bpy_extras
.object_utils
import (
43 from bpy_extras
.image_utils
import load_image
45 # -----------------------------------------------------------------------------
46 # Module-level Shared State
48 watched_objects
= {} # used to trigger compositor updates on scene updates
51 # -----------------------------------------------------------------------------
54 def add_driver_prop(driver
, name
, type, id, path
):
55 """Configure a new driver variable."""
56 dv
= driver
.variables
.new()
58 dv
.type = 'SINGLE_PROP'
59 target
= dv
.targets
[0]
62 target
.data_path
= path
65 # -----------------------------------------------------------------------------
68 ImageSpec
= namedtuple(
70 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
72 num_regex
= re
.compile('[0-9]') # Find a single number
73 nums_regex
= re
.compile('[0-9]+') # Find a set of numbers
76 def find_image_sequences(files
):
77 """From a group of files, detect image sequences.
79 This returns a generator of tuples, which contain the filename,
80 start frame, and length of the detected sequence
82 >>> list(find_image_sequences([
83 ... "test2-001.jp2", "test2-002.jp2",
84 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
86 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
89 files
= iter(sorted(files
))
95 for filename
in files
:
96 new_pattern
= num_regex
.sub('#', filename
)
97 new_matches
= list(map(int, nums_regex
.findall(filename
)))
98 if new_pattern
== pattern
:
99 # this file looks like it may be in sequence from the previous
101 # if there are multiple sets of numbers, figure out what changed
103 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
108 # did it only change by one?
109 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
111 # We expect this to increment
121 # No continuation -> spit out what we found and reset counters
124 yield prev_file
, matches
[segment
], length
126 yield prev_file
, 1, 1
129 matches
= new_matches
130 pattern
= new_pattern
136 yield prev_file
, matches
[segment
], length
138 yield prev_file
, 1, 1
141 def load_images(filenames
, directory
, force_reload
=False, frame_start
=1, find_sequences
=False):
142 """Wrapper for bpy's load_image
144 Loads a set of images, movies, or even image sequences
145 Returns a generator of ImageSpec wrapper objects later used for texture setup
147 if find_sequences
: # if finding sequences, we need some pre-processing first
148 file_iter
= find_image_sequences(filenames
)
150 file_iter
= zip(filenames
, repeat(1), repeat(1))
152 for filename
, offset
, frames
in file_iter
:
153 image
= load_image(filename
, directory
, check_existing
=True, force_reload
=force_reload
)
155 # Size is unavailable for sequences, so we grab it early
156 size
= tuple(image
.size
)
158 if image
.source
== 'MOVIE':
160 # This number is only valid when read a second time in 2.77
161 # This repeated line is not a mistake
162 frames
= image
.frame_duration
163 frames
= image
.frame_duration
165 elif frames
> 1: # Not movie, but multiple frames -> image sequence
166 image
.source
= 'SEQUENCE'
168 yield ImageSpec(image
, size
, frame_start
, offset
- 1, frames
)
171 # -----------------------------------------------------------------------------
172 # Position & Size Helpers
174 def offset_planes(planes
, gap
, axis
):
175 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
177 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
178 obj2 0.5 blender units away from obj1 along the local positive Z axis.
180 This is in local space, not world space, so all planes should share
181 a common scale and rotation.
185 for current
in planes
[1:]:
186 local_offset
= abs((prior
.dimensions
+ current
.dimensions
).dot(axis
)) / 2.0 + gap
188 offset
+= local_offset
* axis
189 current
.location
= current
.matrix_world
@ offset
194 def compute_camera_size(context
, center
, fill_mode
, aspect
):
195 """Determine how large an object needs to be to fit or fill the camera's field of view."""
196 scene
= context
.scene
197 camera
= scene
.camera
198 view_frame
= camera
.data
.view_frame(scene
=scene
)
200 Vector([max(v
[i
] for v
in view_frame
) for i
in range(3)]) - \
201 Vector([min(v
[i
] for v
in view_frame
) for i
in range(3)])
202 camera_aspect
= frame_size
.x
/ frame_size
.y
204 # Convert the frame size to the correct sizing at a given distance
205 if camera
.type == 'ORTHO':
206 frame_size
= frame_size
.xy
208 # Perspective transform
209 distance
= world_to_camera_view(scene
, camera
, center
).z
210 frame_size
= distance
* frame_size
.xy
/ (-view_frame
[0].z
)
212 # Determine what axis to match to the camera
213 match_axis
= 0 # match the Y axis size
214 match_aspect
= aspect
215 if (fill_mode
== 'FILL' and aspect
> camera_aspect
) or \
216 (fill_mode
== 'FIT' and aspect
< camera_aspect
):
217 match_axis
= 1 # match the X axis size
218 match_aspect
= 1.0 / aspect
220 # scale the other axis to the correct aspect
221 frame_size
[1 - match_axis
] = frame_size
[match_axis
] / match_aspect
226 def center_in_camera(scene
, camera
, obj
, axis
=(1, 1)):
227 """Center object along specified axis of the camera"""
228 camera_matrix_col
= camera
.matrix_world
.col
229 location
= obj
.location
231 # Vector from the camera's world coordinate center to the object's center
232 delta
= camera_matrix_col
[3].xyz
- location
234 # How far off center we are along the camera's local X
235 camera_x_mag
= delta
.dot(camera_matrix_col
[0].xyz
) * axis
[0]
236 # How far off center we are along the camera's local Y
237 camera_y_mag
= delta
.dot(camera_matrix_col
[1].xyz
) * axis
[1]
239 # Now offset only along camera local axis
240 offset
= camera_matrix_col
[0].xyz
* camera_x_mag
+ \
241 camera_matrix_col
[1].xyz
* camera_y_mag
243 obj
.location
= location
+ offset
246 # -----------------------------------------------------------------------------
249 def get_input_nodes(node
, links
):
250 """Get nodes that are a inputs to the given node"""
251 # Get all links going to node.
252 input_links
= {lnk
for lnk
in links
if lnk
.to_node
== node
}
253 # Sort those links, get their input nodes (and avoid doubles!).
256 for socket
in node
.inputs
:
258 for link
in input_links
:
261 # Node already treated!
263 elif link
.to_socket
== socket
:
264 sorted_nodes
.append(nd
)
267 input_links
-= done_links
271 def auto_align_nodes(node_tree
):
272 """Given a shader node tree, arrange nodes neatly relative to the output node."""
275 nodes
= node_tree
.nodes
276 links
= node_tree
.links
279 if node
.type == 'OUTPUT_MATERIAL' or node
.type == 'GROUP_OUTPUT':
283 else: # Just in case there is no output
287 from_nodes
= get_input_nodes(to_node
, links
)
288 for i
, node
in enumerate(from_nodes
):
289 node
.location
.x
= min(node
.location
.x
, to_node
.location
.x
- x_gap
)
290 node
.location
.y
= to_node
.location
.y
291 node
.location
.y
-= i
* y_gap
292 node
.location
.y
+= (len(from_nodes
) - 1) * y_gap
/ (len(from_nodes
))
298 def clean_node_tree(node_tree
):
299 """Clear all nodes in a shader node tree except the output.
301 Returns the output node
303 nodes
= node_tree
.nodes
304 for node
in list(nodes
): # copy to avoid altering the loop's data source
305 if not node
.type == 'OUTPUT_MATERIAL':
308 return node_tree
.nodes
[0]
311 def get_shadeless_node(dest_node_tree
):
312 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
314 node_tree
= bpy
.data
.node_groups
['IAP_SHADELESS']
317 # need to build node shadeless node group
318 node_tree
= bpy
.data
.node_groups
.new('IAP_SHADELESS', 'ShaderNodeTree')
319 output_node
= node_tree
.nodes
.new('NodeGroupOutput')
320 input_node
= node_tree
.nodes
.new('NodeGroupInput')
322 node_tree
.outputs
.new('NodeSocketShader', 'Shader')
323 node_tree
.inputs
.new('NodeSocketColor', 'Color')
325 # This could be faster as a transparent shader, but then no ambient occlusion
326 diffuse_shader
= node_tree
.nodes
.new('ShaderNodeBsdfDiffuse')
327 node_tree
.links
.new(diffuse_shader
.inputs
[0], input_node
.outputs
[0])
329 emission_shader
= node_tree
.nodes
.new('ShaderNodeEmission')
330 node_tree
.links
.new(emission_shader
.inputs
[0], input_node
.outputs
[0])
332 light_path
= node_tree
.nodes
.new('ShaderNodeLightPath')
333 is_glossy_ray
= light_path
.outputs
['Is Glossy Ray']
334 is_shadow_ray
= light_path
.outputs
['Is Shadow Ray']
335 ray_depth
= light_path
.outputs
['Ray Depth']
336 transmission_depth
= light_path
.outputs
['Transmission Depth']
338 unrefracted_depth
= node_tree
.nodes
.new('ShaderNodeMath')
339 unrefracted_depth
.operation
= 'SUBTRACT'
340 unrefracted_depth
.label
= 'Bounce Count'
341 node_tree
.links
.new(unrefracted_depth
.inputs
[0], ray_depth
)
342 node_tree
.links
.new(unrefracted_depth
.inputs
[1], transmission_depth
)
344 refracted
= node_tree
.nodes
.new('ShaderNodeMath')
345 refracted
.operation
= 'SUBTRACT'
346 refracted
.label
= 'Camera or Refracted'
347 refracted
.inputs
[0].default_value
= 1.0
348 node_tree
.links
.new(refracted
.inputs
[1], unrefracted_depth
.outputs
[0])
350 reflection_limit
= node_tree
.nodes
.new('ShaderNodeMath')
351 reflection_limit
.operation
= 'SUBTRACT'
352 reflection_limit
.label
= 'Limit Reflections'
353 reflection_limit
.inputs
[0].default_value
= 2.0
354 node_tree
.links
.new(reflection_limit
.inputs
[1], ray_depth
)
356 camera_reflected
= node_tree
.nodes
.new('ShaderNodeMath')
357 camera_reflected
.operation
= 'MULTIPLY'
358 camera_reflected
.label
= 'Camera Ray to Glossy'
359 node_tree
.links
.new(camera_reflected
.inputs
[0], reflection_limit
.outputs
[0])
360 node_tree
.links
.new(camera_reflected
.inputs
[1], is_glossy_ray
)
362 shadow_or_reflect
= node_tree
.nodes
.new('ShaderNodeMath')
363 shadow_or_reflect
.operation
= 'MAXIMUM'
364 shadow_or_reflect
.label
= 'Shadow or Reflection?'
365 node_tree
.links
.new(shadow_or_reflect
.inputs
[0], camera_reflected
.outputs
[0])
366 node_tree
.links
.new(shadow_or_reflect
.inputs
[1], is_shadow_ray
)
368 shadow_or_reflect_or_refract
= node_tree
.nodes
.new('ShaderNodeMath')
369 shadow_or_reflect_or_refract
.operation
= 'MAXIMUM'
370 shadow_or_reflect_or_refract
.label
= 'Shadow, Reflect or Refract?'
371 node_tree
.links
.new(shadow_or_reflect_or_refract
.inputs
[0], shadow_or_reflect
.outputs
[0])
372 node_tree
.links
.new(shadow_or_reflect_or_refract
.inputs
[1], refracted
.outputs
[0])
374 mix_shader
= node_tree
.nodes
.new('ShaderNodeMixShader')
375 node_tree
.links
.new(mix_shader
.inputs
[0], shadow_or_reflect_or_refract
.outputs
[0])
376 node_tree
.links
.new(mix_shader
.inputs
[1], diffuse_shader
.outputs
[0])
377 node_tree
.links
.new(mix_shader
.inputs
[2], emission_shader
.outputs
[0])
379 node_tree
.links
.new(output_node
.inputs
[0], mix_shader
.outputs
[0])
381 auto_align_nodes(node_tree
)
383 group_node
= dest_node_tree
.nodes
.new("ShaderNodeGroup")
384 group_node
.node_tree
= node_tree
389 # -----------------------------------------------------------------------------
390 # Corner Pin Driver Helpers
392 @bpy.app
.handlers
.persistent
393 def check_drivers(*args
, **kwargs
):
394 """Check if watched objects in a scene have changed and trigger compositor update
396 This is part of a hack to ensure the compositor updates
397 itself when the objects used for drivers change.
399 It only triggers if transformation matricies change to avoid
400 a cyclic loop of updates.
402 if not watched_objects
:
403 # if there is nothing to watch, don't bother running this
404 bpy
.app
.handlers
.depsgraph_update_post
.remove(check_drivers
)
408 for name
, matrix
in list(watched_objects
.items()):
410 obj
= bpy
.data
.objects
[name
]
412 # The user must have removed this object
413 del watched_objects
[name
]
415 new_matrix
= tuple(map(tuple, obj
.matrix_world
)).__hash
__()
416 if new_matrix
!= matrix
:
417 watched_objects
[name
] = new_matrix
421 # Trick to re-evaluate drivers
422 bpy
.context
.scene
.frame_current
= bpy
.context
.scene
.frame_current
425 def register_watched_object(obj
):
426 """Register an object to be monitored for transformation changes"""
429 # known object? -> we're done
430 if name
in watched_objects
:
433 if not watched_objects
:
434 # make sure check_drivers is active
435 bpy
.app
.handlers
.depsgraph_update_post
.append(check_drivers
)
437 watched_objects
[name
] = None
440 def find_plane_corner(object_name
, x
, y
, axis
, camera
=None, *args
, **kwargs
):
441 """Find the location in camera space of a plane's corner"""
443 # I've added args / kwargs as a compatibility measure with future versions
444 warnings
.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
446 plane
= bpy
.data
.objects
[object_name
]
448 # Passing in camera doesn't work before 2.78, so we use the current one
449 camera
= camera
or bpy
.context
.scene
.camera
451 # Hack to ensure compositor updates on future changes
452 register_watched_object(camera
)
453 register_watched_object(plane
)
455 scale
= plane
.scale
* 2.0
456 v
= plane
.dimensions
.copy()
459 v
= plane
.matrix_world
@ v
461 camera_vertex
= world_to_camera_view(
462 bpy
.context
.scene
, camera
, v
)
464 return camera_vertex
[axis
]
467 @bpy.app
.handlers
.persistent
468 def register_driver(*args
, **kwargs
):
469 """Register the find_plane_corner function for use with drivers"""
470 bpy
.app
.driver_namespace
['import_image__find_plane_corner'] = find_plane_corner
473 # -----------------------------------------------------------------------------
474 # Compositing Helpers
476 def group_in_frame(node_tree
, name
, nodes
):
477 frame_node
= node_tree
.nodes
.new("NodeFrame")
478 frame_node
.label
= name
479 frame_node
.name
= name
+ "_frame"
481 min_pos
= Vector(nodes
[0].location
)
482 max_pos
= min_pos
.copy()
485 top_left
= node
.location
486 bottom_right
= top_left
+ Vector((node
.width
, -node
.height
))
489 min_pos
[i
] = min(min_pos
[i
], top_left
[i
], bottom_right
[i
])
490 max_pos
[i
] = max(max_pos
[i
], top_left
[i
], bottom_right
[i
])
492 node
.parent
= frame_node
494 frame_node
.width
= max_pos
[0] - min_pos
[0] + 50
495 frame_node
.height
= max(max_pos
[1] - min_pos
[1] + 50, 450)
496 frame_node
.shrink
= True
501 def position_frame_bottom_left(node_tree
, frame_node
):
502 newpos
= Vector((100000, 100000)) # start reasonably far top / right
504 # Align with the furthest left
505 for node
in node_tree
.nodes
.values():
506 if node
!= frame_node
and node
.parent
!= frame_node
:
507 newpos
.x
= min(newpos
.x
, node
.location
.x
+ 30)
509 # As high as we can get without overlapping anything to the right
510 for node
in node_tree
.nodes
.values():
511 if node
!= frame_node
and not node
.parent
:
512 if node
.location
.x
< newpos
.x
+ frame_node
.width
:
513 print("Below", node
.name
, node
.location
, node
.height
, node
.dimensions
)
514 newpos
.y
= min(newpos
.y
, node
.location
.y
- max(node
.dimensions
.y
, node
.height
) - 20)
516 frame_node
.location
= newpos
519 def setup_compositing(context
, plane
, img_spec
):
520 # Node Groups only work with "new" dependency graph and even
521 # then it has some problems with not updating the first time
522 # So instead this groups with a node frame, which works reliably
524 scene
= context
.scene
525 scene
.use_nodes
= True
526 node_tree
= scene
.node_tree
529 image_node
= node_tree
.nodes
.new("CompositorNodeImage")
530 image_node
.name
= name
+ "_image"
531 image_node
.image
= img_spec
.image
532 image_node
.location
= Vector((0, 0))
533 image_node
.frame_start
= img_spec
.frame_start
534 image_node
.frame_offset
= img_spec
.frame_offset
535 image_node
.frame_duration
= img_spec
.frame_duration
537 scale_node
= node_tree
.nodes
.new("CompositorNodeScale")
538 scale_node
.name
= name
+ "_scale"
539 scale_node
.space
= 'RENDER_SIZE'
540 scale_node
.location
= image_node
.location
+ \
541 Vector((image_node
.width
+ 20, 0))
542 scale_node
.show_options
= False
544 cornerpin_node
= node_tree
.nodes
.new("CompositorNodeCornerPin")
545 cornerpin_node
.name
= name
+ "_cornerpin"
546 cornerpin_node
.location
= scale_node
.location
+ \
547 Vector((0, -scale_node
.height
))
549 node_tree
.links
.new(scale_node
.inputs
[0], image_node
.outputs
[0])
550 node_tree
.links
.new(cornerpin_node
.inputs
[0], scale_node
.outputs
[0])
552 # Put all the nodes in a frame for organization
553 frame_node
= group_in_frame(
555 (image_node
, scale_node
, cornerpin_node
)
558 # Position frame at bottom / left
559 position_frame_bottom_left(node_tree
, frame_node
)
562 for corner
in cornerpin_node
.inputs
[1:]:
563 id = corner
.identifier
564 x
= -1 if 'Left' in id else 1
565 y
= -1 if 'Lower' in id else 1
566 drivers
= corner
.driver_add('default_value')
567 for i
, axis_fcurve
in enumerate(drivers
):
568 driver
= axis_fcurve
.driver
569 # Always use the current camera
570 add_driver_prop(driver
, 'camera', 'SCENE', scene
, 'camera')
571 # Track camera location to ensure Deps Graph triggers (not used in the call)
572 add_driver_prop(driver
, 'cam_loc_x', 'OBJECT', scene
.camera
, 'location[0]')
573 # Don't break if the name changes
574 add_driver_prop(driver
, 'name', 'OBJECT', plane
, 'name')
575 driver
.expression
= "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
579 driver
.type = 'SCRIPTED'
580 driver
.is_valid
= True
581 axis_fcurve
.is_valid
= True
582 driver
.expression
= "%s" % driver
.expression
584 context
.view_layer
.update()
587 # -----------------------------------------------------------------------------
590 class IMPORT_IMAGE_OT_to_plane(Operator
, AddObjectHelper
):
591 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
593 bl_idname
= "import_image.to_plane"
594 bl_label
= "Import Images as Planes"
595 bl_options
= {'REGISTER', 'PRESET', 'UNDO'}
597 # ----------------------
598 # File dialog properties
599 files
: CollectionProperty(type=bpy
.types
.OperatorFileListElement
, options
={'HIDDEN', 'SKIP_SAVE'})
601 directory
: StringProperty(maxlen
=1024, subtype
='FILE_PATH', options
={'HIDDEN', 'SKIP_SAVE'})
603 filter_image
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
604 filter_movie
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
605 filter_folder
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
607 # ----------------------
608 # Properties - Importing
609 force_reload
: BoolProperty(
610 name
="Force Reload", default
=False,
611 description
="Force reloading of the image if already opened elsewhere in Blender"
614 image_sequence
: BoolProperty(
615 name
="Animate Image Sequences", default
=False,
616 description
="Import sequentially numbered images as an animated "
617 "image sequence instead of separate planes"
620 # -------------------------------------
621 # Properties - Position and Orientation
622 axis_id_to_vector
= {
623 'X+': Vector(( 1, 0, 0)),
624 'Y+': Vector(( 0, 1, 0)),
625 'Z+': Vector(( 0, 0, 1)),
626 'X-': Vector((-1, 0, 0)),
627 'Y-': Vector(( 0, -1, 0)),
628 'Z-': Vector(( 0, 0, -1)),
631 offset
: BoolProperty(name
="Offset Planes", default
=True, description
="Offset Planes From Each Other")
634 ('X+', "X+", "Side by Side to the Left"),
635 ('Y+', "Y+", "Side by Side, Downward"),
636 ('Z+', "Z+", "Stacked Above"),
637 ('X-', "X-", "Side by Side to the Right"),
638 ('Y-', "Y-", "Side by Side, Upward"),
639 ('Z-', "Z-", "Stacked Below"),
641 offset_axis
: EnumProperty(
642 name
="Orientation", default
='X+', items
=OFFSET_MODES
,
643 description
="How planes are oriented relative to each others' local axis"
646 offset_amount
: FloatProperty(
647 name
="Offset", soft_min
=0, default
=0.1, description
="Space between planes",
648 subtype
='DISTANCE', unit
='LENGTH'
652 ('X+', "X+", "Facing Positive X"),
653 ('Y+', "Y+", "Facing Positive Y"),
654 ('Z+', "Z+ (Up)", "Facing Positive Z"),
655 ('X-', "X-", "Facing Negative X"),
656 ('Y-', "Y-", "Facing Negative Y"),
657 ('Z-', "Z- (Down)", "Facing Negative Z"),
658 ('CAM', "Face Camera", "Facing Camera"),
659 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
661 align_axis
: EnumProperty(
662 name
="Align", default
='CAM_AX', items
=AXIS_MODES
,
663 description
="How to align the planes"
665 # prev_align_axis is used only by update_size_model
666 prev_align_axis
: EnumProperty(
667 items
=AXIS_MODES
+ (('NONE', '', ''),), default
='NONE', options
={'HIDDEN', 'SKIP_SAVE'})
668 align_track
: BoolProperty(
669 name
="Track Camera", default
=False, description
="Always face the camera"
674 def update_size_mode(self
, context
):
675 """If sizing relative to the camera, always face the camera"""
676 if self
.size_mode
== 'CAMERA':
677 self
.prev_align_axis
= self
.align_axis
678 self
.align_axis
= 'CAM'
680 # if a different alignment was set revert to that when
681 # size mode is changed
682 if self
.prev_align_axis
!= 'NONE':
683 self
.align_axis
= self
.prev_align_axis
684 self
._prev
_align
_axis
= 'NONE'
687 ('ABSOLUTE', "Absolute", "Use absolute size"),
688 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
689 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
690 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
692 size_mode
: EnumProperty(
693 name
="Size Mode", default
='ABSOLUTE', items
=SIZE_MODES
,
694 update
=update_size_mode
,
695 description
="How the size of the plane is computed")
698 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
699 ('FIT', "Fit", "Fit entire image within the camera frame"),
701 fill_mode
: EnumProperty(name
="Scale", default
='FILL', items
=FILL_MODES
,
702 description
="How large in the camera frame is the plane")
704 height
: FloatProperty(name
="Height", description
="Height of the created plane",
705 default
=1.0, min=0.001, soft_min
=0.001, subtype
='DISTANCE', unit
='LENGTH')
707 factor
: FloatProperty(name
="Definition", min=1.0, default
=600.0,
708 description
="Number of pixels per inch or Blender Unit")
710 # ------------------------------
711 # Properties - Material / Shader
713 ('PRINCIPLED',"Principled","Principled Shader"),
714 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
715 ('EMISSION', "Emit", "Emission Shader"),
717 shader
: EnumProperty(name
="Shader", items
=SHADERS
, default
='PRINCIPLED', description
="Node shader to use")
719 emit_strength
: FloatProperty(
720 name
="Strength", min=0.0, default
=1.0, soft_max
=10.0,
721 step
=100, description
="Brightness of Emission Texture")
723 overwrite_material
: BoolProperty(
724 name
="Overwrite Material", default
=True,
725 description
="Overwrite existing Material (based on material name)")
727 compositing_nodes
: BoolProperty(
728 name
="Setup Corner Pin", default
=False,
729 description
="Build Compositor Nodes to reference this image "
730 "without re-rendering")
734 use_transparency
: BoolProperty(
735 name
="Use Alpha", default
=True,
736 description
="Use alpha channel for transparency")
738 t
= bpy
.types
.Image
.bl_rna
.properties
["alpha_mode"]
739 alpha_mode_items
= tuple((e
.identifier
, e
.name
, e
.description
) for e
in t
.enum_items
)
740 alpha_mode
: EnumProperty(
741 name
=t
.name
, items
=alpha_mode_items
, default
=t
.default
,
742 description
=t
.description
)
744 t
= bpy
.types
.ImageUser
.bl_rna
.properties
["use_auto_refresh"]
745 use_auto_refresh
: BoolProperty(name
=t
.name
, default
=True, description
=t
.description
)
747 relative
: BoolProperty(name
="Relative Paths", default
=True, description
="Use relative file paths")
751 def draw_import_config(self
, context
):
752 # --- Import Options --- #
756 box
.label(text
="Import Options:", icon
='IMPORT')
758 row
.active
= bpy
.data
.is_saved
759 row
.prop(self
, "relative")
761 box
.prop(self
, "force_reload")
762 box
.prop(self
, "image_sequence")
764 def draw_material_config(self
, context
):
765 # --- Material / Rendering Properties --- #
769 box
.label(text
="Compositing Nodes:", icon
='RENDERLAYERS')
770 box
.prop(self
, "compositing_nodes")
772 box
.label(text
="Material Settings:", icon
='MATERIAL')
775 row
.prop(self
, 'shader', expand
=True)
776 if self
.shader
== 'EMISSION':
777 box
.prop(self
, "emit_strength")
779 engine
= context
.scene
.render
.engine
780 if engine
not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
781 box
.label(text
="%s is not supported" % engine
, icon
='ERROR')
783 box
.prop(self
, "overwrite_material")
785 box
.label(text
="Texture Settings:", icon
='TEXTURE')
787 row
.prop(self
, "use_transparency")
789 sub
.active
= self
.use_transparency
790 sub
.prop(self
, "alpha_mode", text
="")
791 box
.prop(self
, "use_auto_refresh")
793 def draw_spatial_config(self
, context
):
794 # --- Spatial Properties: Position, Size and Orientation --- #
798 box
.label(text
="Position:", icon
='SNAP_GRID')
799 box
.prop(self
, "offset")
802 row
.prop(self
, "offset_axis", expand
=True)
804 row
.prop(self
, "offset_amount")
805 col
.enabled
= self
.offset
807 box
.label(text
="Plane dimensions:", icon
='ARROW_LEFTRIGHT')
809 row
.prop(self
, "size_mode", expand
=True)
810 if self
.size_mode
== 'ABSOLUTE':
811 box
.prop(self
, "height")
812 elif self
.size_mode
== 'CAMERA':
814 row
.prop(self
, "fill_mode", expand
=True)
816 box
.prop(self
, "factor")
818 box
.label(text
="Orientation:")
820 row
.enabled
= 'CAM' not in self
.size_mode
821 row
.prop(self
, "align_axis")
823 row
.enabled
= 'CAM' in self
.align_axis
824 row
.alignment
= 'RIGHT'
825 row
.prop(self
, "align_track")
827 def draw(self
, context
):
829 # Draw configuration sections
830 self
.draw_import_config(context
)
831 self
.draw_material_config(context
)
832 self
.draw_spatial_config(context
)
834 # -------------------------------------------------------------------------
836 def invoke(self
, context
, event
):
837 engine
= context
.scene
.render
.engine
838 if engine
not in {'CYCLES', 'BLENDER_EEVEE'}:
839 if engine
!= 'BLENDER_WORKBENCH':
840 self
.report({'ERROR'}, "Cannot generate materials for unknown %s render engine" % engine
)
843 self
.report({'WARNING'},
844 "Generating Cycles/EEVEE compatible material, but won't be visible with %s engine" % engine
)
847 context
.window_manager
.fileselect_add(self
)
848 return {'RUNNING_MODAL'}
850 def execute(self
, context
):
851 if not bpy
.data
.is_saved
:
852 self
.relative
= False
854 # this won't work in edit mode
855 editmode
= context
.preferences
.edit
.use_enter_edit_mode
856 context
.preferences
.edit
.use_enter_edit_mode
= False
857 if context
.active_object
and context
.active_object
.mode
!= 'OBJECT':
858 bpy
.ops
.object.mode_set(mode
='OBJECT')
860 self
.import_images(context
)
862 context
.preferences
.edit
.use_enter_edit_mode
= editmode
866 def import_images(self
, context
):
868 # load images / sequences
869 images
= tuple(load_images(
870 (fn
.name
for fn
in self
.files
),
872 force_reload
=self
.force_reload
,
873 find_sequences
=self
.image_sequence
876 # Create individual planes
877 planes
= [self
.single_image_spec_to_plane(context
, img_spec
) for img_spec
in images
]
879 context
.view_layer
.update()
881 # Align planes relative to each other
883 offset_axis
= self
.axis_id_to_vector
[self
.offset_axis
]
884 offset_planes(planes
, self
.offset_amount
, offset_axis
)
886 if self
.size_mode
== 'CAMERA' and offset_axis
.z
:
888 x
, y
= compute_camera_size(
889 context
, plane
.location
,
890 self
.fill_mode
, plane
.dimensions
.x
/ plane
.dimensions
.y
)
891 plane
.dimensions
= x
, y
, 0.0
893 # setup new selection
895 plane
.select_set(True)
898 self
.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes
)))
900 # operate on a single image
901 def single_image_spec_to_plane(self
, context
, img_spec
):
904 self
.apply_image_options(img_spec
.image
)
907 engine
= context
.scene
.render
.engine
908 if engine
in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
909 material
= self
.create_cycles_material(context
, img_spec
)
911 # Create and position plane object
912 plane
= self
.create_image_plane(context
, material
.name
, img_spec
)
915 plane
.data
.materials
.append(material
)
917 # If applicable, setup Corner Pin node
918 if self
.compositing_nodes
:
919 setup_compositing(context
, plane
, img_spec
)
923 def apply_image_options(self
, image
):
924 if self
.use_transparency
== False:
925 image
.alpha_mode
= 'NONE'
927 image
.alpha_mode
= self
.alpha_mode
930 try: # can't always find the relative path (between drive letters on windows)
931 image
.filepath
= bpy
.path
.relpath(image
.filepath
)
935 def apply_texture_options(self
, texture
, img_spec
):
936 # Shared by both Cycles and Blender Internal
937 image_user
= texture
.image_user
938 image_user
.use_auto_refresh
= self
.use_auto_refresh
939 image_user
.frame_start
= img_spec
.frame_start
940 image_user
.frame_offset
= img_spec
.frame_offset
941 image_user
.frame_duration
= img_spec
.frame_duration
943 # Image sequences need auto refresh to display reliably
944 if img_spec
.image
.source
== 'SEQUENCE':
945 image_user
.use_auto_refresh
= True
947 texture
.extension
= 'CLIP' # Default of "Repeat" can cause artifacts
949 def apply_material_options(self
, material
, slot
):
952 if self
.use_transparency
:
954 material
.specular_alpha
= 0.0
955 slot
.use_map_alpha
= True
958 material
.specular_alpha
= 1.0
959 slot
.use_map_alpha
= False
961 material
.specular_intensity
= 0
962 material
.diffuse_intensity
= 1.0
963 material
.use_transparency
= self
.use_transparency
964 material
.transparency_method
= 'Z_TRANSPARENCY'
965 material
.use_shadeless
= (shader
== 'SHADELESS')
966 material
.use_transparent_shadows
= (shader
== 'DIFFUSE')
967 material
.emit
= self
.emit_strength
if shader
== 'EMISSION' else 0.0
969 # -------------------------------------------------------------------------
971 def create_cycles_texnode(self
, context
, node_tree
, img_spec
):
972 tex_image
= node_tree
.nodes
.new('ShaderNodeTexImage')
973 tex_image
.image
= img_spec
.image
974 tex_image
.show_texture
= True
975 self
.apply_texture_options(tex_image
, img_spec
)
978 def create_cycles_material(self
, context
, img_spec
):
979 image
= img_spec
.image
980 name_compat
= bpy
.path
.display_name_from_filepath(image
.filepath
)
982 if self
.overwrite_material
:
983 for mat
in bpy
.data
.materials
:
984 if mat
.name
== name_compat
:
987 material
= bpy
.data
.materials
.new(name
=name_compat
)
989 material
.use_nodes
= True
990 if self
.use_transparency
:
991 material
.blend_method
= 'BLEND'
992 node_tree
= material
.node_tree
993 out_node
= clean_node_tree(node_tree
)
995 tex_image
= self
.create_cycles_texnode(context
, node_tree
, img_spec
)
997 if self
.shader
== 'PRINCIPLED':
998 core_shader
= node_tree
.nodes
.new('ShaderNodeBsdfPrincipled')
999 elif self
.shader
== 'SHADELESS':
1000 core_shader
= get_shadeless_node(node_tree
)
1001 elif self
.shader
== 'EMISSION':
1002 core_shader
= node_tree
.nodes
.new('ShaderNodeBsdfPrincipled')
1003 core_shader
.inputs
['Emission Strength'].default_value
= self
.emit_strength
1004 core_shader
.inputs
['Base Color'].default_value
= (0.0, 0.0, 0.0, 1.0)
1005 core_shader
.inputs
['Specular'].default_value
= 0.0
1007 # Connect color from texture
1008 if self
.shader
in {'PRINCIPLED', 'SHADELESS'}:
1009 node_tree
.links
.new(core_shader
.inputs
[0], tex_image
.outputs
['Color'])
1010 elif self
.shader
== 'EMISSION':
1011 node_tree
.links
.new(core_shader
.inputs
['Emission'], tex_image
.outputs
['Color'])
1013 if self
.use_transparency
:
1014 if self
.shader
in {'PRINCIPLED', 'EMISSION'}:
1015 node_tree
.links
.new(core_shader
.inputs
['Alpha'], tex_image
.outputs
['Alpha'])
1017 bsdf_transparent
= node_tree
.nodes
.new('ShaderNodeBsdfTransparent')
1019 mix_shader
= node_tree
.nodes
.new('ShaderNodeMixShader')
1020 node_tree
.links
.new(mix_shader
.inputs
['Fac'], tex_image
.outputs
['Alpha'])
1021 node_tree
.links
.new(mix_shader
.inputs
[1], bsdf_transparent
.outputs
['BSDF'])
1022 node_tree
.links
.new(mix_shader
.inputs
[2], core_shader
.outputs
[0])
1023 core_shader
= mix_shader
1025 node_tree
.links
.new(out_node
.inputs
['Surface'], core_shader
.outputs
[0])
1027 auto_align_nodes(node_tree
)
1030 # -------------------------------------------------------------------------
1032 def create_image_plane(self
, context
, name
, img_spec
):
1034 width
, height
= self
.compute_plane_size(context
, img_spec
)
1037 bpy
.ops
.mesh
.primitive_plane_add('INVOKE_REGION_WIN')
1038 plane
= context
.active_object
1039 # Why does mesh.primitive_plane_add leave the object in edit mode???
1040 if plane
.mode
!= 'OBJECT':
1041 bpy
.ops
.object.mode_set(mode
='OBJECT')
1042 plane
.dimensions
= width
, height
, 0.0
1043 plane
.data
.name
= plane
.name
= name
1044 bpy
.ops
.object.transform_apply(location
=False, rotation
=False, scale
=True)
1046 # If sizing for camera, also insert into the camera's field of view
1047 if self
.size_mode
== 'CAMERA':
1048 offset_axis
= self
.axis_id_to_vector
[self
.offset_axis
]
1049 translate_axis
= [0 if offset_axis
[i
] else 1 for i
in (0, 1)]
1050 center_in_camera(context
.scene
, context
.scene
.camera
, plane
, translate_axis
)
1052 self
.align_plane(context
, plane
)
1056 def compute_plane_size(self
, context
, img_spec
):
1057 """Given the image size in pixels and location, determine size of plane"""
1058 px
, py
= img_spec
.size
1061 if px
== 0 or py
== 0:
1064 if self
.size_mode
== 'ABSOLUTE':
1068 elif self
.size_mode
== 'CAMERA':
1069 x
, y
= compute_camera_size(
1070 context
, context
.scene
.cursor
.location
,
1071 self
.fill_mode
, px
/ py
1074 elif self
.size_mode
== 'DPI':
1075 fact
= 1 / self
.factor
/ context
.scene
.unit_settings
.scale_length
* 0.0254
1079 else: # elif self.size_mode == 'DPBU'
1080 fact
= 1 / self
.factor
1086 def align_plane(self
, context
, plane
):
1087 """Pick an axis and align the plane to it"""
1088 if 'CAM' in self
.align_axis
:
1090 camera
= context
.scene
.camera
1092 # Find the axis that best corresponds to the camera's view direction
1093 axis
= camera
.matrix_world
@ \
1094 Vector((0, 0, 1)) - camera
.matrix_world
.col
[3].xyz
1095 # pick the axis with the greatest magnitude
1096 mag
= max(map(abs, axis
))
1097 # And use that axis & direction
1099 n
/ mag
if abs(n
) == mag
else 0.0
1103 # No camera? Just face Z axis
1104 axis
= Vector((0, 0, 1))
1105 self
.align_axis
= 'Z+'
1108 axis
= self
.axis_id_to_vector
[self
.align_axis
]
1110 # rotate accordingly for x/y axiis
1112 plane
.rotation_euler
.x
= pi
/ 2
1115 plane
.rotation_euler
.z
= pi
1117 plane
.rotation_euler
.z
= 0
1119 plane
.rotation_euler
.z
= pi
/ 2
1121 plane
.rotation_euler
.z
= -pi
/ 2
1123 # or flip 180 degrees for negative z
1125 plane
.rotation_euler
.y
= pi
1127 if self
.align_axis
== 'CAM':
1128 constraint
= plane
.constraints
.new('COPY_ROTATION')
1129 constraint
.target
= camera
1130 constraint
.use_x
= constraint
.use_y
= constraint
.use_z
= True
1131 if not self
.align_track
:
1132 bpy
.ops
.object.visual_transform_apply()
1133 plane
.constraints
.clear()
1135 if self
.align_axis
== 'CAM_AX' and self
.align_track
:
1136 constraint
= plane
.constraints
.new('LOCKED_TRACK')
1137 constraint
.target
= camera
1138 constraint
.track_axis
= 'TRACK_Z'
1139 constraint
.lock_axis
= 'LOCK_Y'
1142 # -----------------------------------------------------------------------------
1145 def import_images_button(self
, context
):
1146 self
.layout
.operator(IMPORT_IMAGE_OT_to_plane
.bl_idname
, text
="Images as Planes", icon
='TEXTURE')
1150 IMPORT_IMAGE_OT_to_plane
,
1156 bpy
.utils
.register_class(cls
)
1158 bpy
.types
.TOPBAR_MT_file_import
.append(import_images_button
)
1159 bpy
.types
.VIEW3D_MT_image_add
.append(import_images_button
)
1161 bpy
.app
.handlers
.load_post
.append(register_driver
)
1166 bpy
.types
.TOPBAR_MT_file_import
.remove(import_images_button
)
1167 bpy
.types
.VIEW3D_MT_image_add
.remove(import_images_button
)
1169 # This will only exist if drivers are active
1170 if check_drivers
in bpy
.app
.handlers
.depsgraph_update_post
:
1171 bpy
.app
.handlers
.depsgraph_update_post
.remove(check_drivers
)
1173 bpy
.app
.handlers
.load_post
.remove(register_driver
)
1174 del bpy
.app
.driver_namespace
['import_image__find_plane_corner']
1177 bpy
.utils
.unregister_class(cls
)
1180 if __name__
== "__main__":
1181 # Run simple doc tests