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