1 # SPDX-License-Identifier: GPL-2.0-or-later
4 "name": "Import Images as Planes",
5 "author": "Florian Meyer (tstscr), mont29, matali, Ted Schundler (SpkyElctrc)",
8 "location": "File > Import > Images as Planes or Add > Mesh > Images as Planes",
9 "description": "Imports images and creates planes with the appropriate aspect ratio. "
10 "The images are mapped to the planes.",
12 "doc_url": "{BLENDER_MANUAL_URL}/addons/import_export/images_as_planes.html",
13 "support": 'OFFICIAL',
14 "category": "Import-Export",
20 from itertools
import count
, repeat
21 from collections
import namedtuple
25 from bpy
.types
import Operator
26 from mathutils
import Vector
28 from bpy
.props
import (
36 from bpy_extras
.object_utils
import (
41 from bpy_extras
.image_utils
import load_image
43 # -----------------------------------------------------------------------------
44 # Module-level Shared State
46 watched_objects
= {} # used to trigger compositor updates on scene updates
49 # -----------------------------------------------------------------------------
52 def add_driver_prop(driver
, name
, type, id, path
):
53 """Configure a new driver variable."""
54 dv
= driver
.variables
.new()
56 dv
.type = 'SINGLE_PROP'
57 target
= dv
.targets
[0]
60 target
.data_path
= path
63 # -----------------------------------------------------------------------------
66 ImageSpec
= namedtuple(
68 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
70 num_regex
= re
.compile('[0-9]') # Find a single number
71 nums_regex
= re
.compile('[0-9]+') # Find a set of numbers
74 def find_image_sequences(files
):
75 """From a group of files, detect image sequences.
77 This returns a generator of tuples, which contain the filename,
78 start frame, and length of the detected sequence
80 >>> list(find_image_sequences([
81 ... "test2-001.jp2", "test2-002.jp2",
82 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
84 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
87 files
= iter(sorted(files
))
93 for filename
in files
:
94 new_pattern
= num_regex
.sub('#', filename
)
95 new_matches
= list(map(int, nums_regex
.findall(filename
)))
96 if new_pattern
== pattern
:
97 # this file looks like it may be in sequence from the previous
99 # if there are multiple sets of numbers, figure out what changed
101 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
106 # did it only change by one?
107 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
109 # We expect this to increment
119 # No continuation -> spit out what we found and reset counters
122 yield prev_file
, matches
[segment
], length
124 yield prev_file
, 1, 1
127 matches
= new_matches
128 pattern
= new_pattern
134 yield prev_file
, matches
[segment
], length
136 yield prev_file
, 1, 1
139 def load_images(filenames
, directory
, force_reload
=False, frame_start
=1, find_sequences
=False):
140 """Wrapper for bpy's load_image
142 Loads a set of images, movies, or even image sequences
143 Returns a generator of ImageSpec wrapper objects later used for texture setup
145 if find_sequences
: # if finding sequences, we need some pre-processing first
146 file_iter
= find_image_sequences(filenames
)
148 file_iter
= zip(filenames
, repeat(1), repeat(1))
150 for filename
, offset
, frames
in file_iter
:
151 image
= load_image(filename
, directory
, check_existing
=True, force_reload
=force_reload
)
153 # Size is unavailable for sequences, so we grab it early
154 size
= tuple(image
.size
)
156 if image
.source
== 'MOVIE':
158 # This number is only valid when read a second time in 2.77
159 # This repeated line is not a mistake
160 frames
= image
.frame_duration
161 frames
= image
.frame_duration
163 elif frames
> 1: # Not movie, but multiple frames -> image sequence
164 image
.source
= 'SEQUENCE'
166 yield ImageSpec(image
, size
, frame_start
, offset
- 1, frames
)
169 # -----------------------------------------------------------------------------
170 # Position & Size Helpers
172 def offset_planes(planes
, gap
, axis
):
173 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
175 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
176 obj2 0.5 blender units away from obj1 along the local positive Z axis.
178 This is in local space, not world space, so all planes should share
179 a common scale and rotation.
183 for current
in planes
[1:]:
184 local_offset
= abs((prior
.dimensions
+ current
.dimensions
).dot(axis
)) / 2.0 + gap
186 offset
+= local_offset
* axis
187 current
.location
= current
.matrix_world
@ offset
192 def compute_camera_size(context
, center
, fill_mode
, aspect
):
193 """Determine how large an object needs to be to fit or fill the camera's field of view."""
194 scene
= context
.scene
195 camera
= scene
.camera
196 view_frame
= camera
.data
.view_frame(scene
=scene
)
198 Vector([max(v
[i
] for v
in view_frame
) for i
in range(3)]) - \
199 Vector([min(v
[i
] for v
in view_frame
) for i
in range(3)])
200 camera_aspect
= frame_size
.x
/ frame_size
.y
202 # Convert the frame size to the correct sizing at a given distance
203 if camera
.type == 'ORTHO':
204 frame_size
= frame_size
.xy
206 # Perspective transform
207 distance
= world_to_camera_view(scene
, camera
, center
).z
208 frame_size
= distance
* frame_size
.xy
/ (-view_frame
[0].z
)
210 # Determine what axis to match to the camera
211 match_axis
= 0 # match the Y axis size
212 match_aspect
= aspect
213 if (fill_mode
== 'FILL' and aspect
> camera_aspect
) or \
214 (fill_mode
== 'FIT' and aspect
< camera_aspect
):
215 match_axis
= 1 # match the X axis size
216 match_aspect
= 1.0 / aspect
218 # scale the other axis to the correct aspect
219 frame_size
[1 - match_axis
] = frame_size
[match_axis
] / match_aspect
224 def center_in_camera(scene
, camera
, obj
, axis
=(1, 1)):
225 """Center object along specified axis of the camera"""
226 camera_matrix_col
= camera
.matrix_world
.col
227 location
= obj
.location
229 # Vector from the camera's world coordinate center to the object's center
230 delta
= camera_matrix_col
[3].xyz
- location
232 # How far off center we are along the camera's local X
233 camera_x_mag
= delta
.dot(camera_matrix_col
[0].xyz
) * axis
[0]
234 # How far off center we are along the camera's local Y
235 camera_y_mag
= delta
.dot(camera_matrix_col
[1].xyz
) * axis
[1]
237 # Now offset only along camera local axis
238 offset
= camera_matrix_col
[0].xyz
* camera_x_mag
+ \
239 camera_matrix_col
[1].xyz
* camera_y_mag
241 obj
.location
= location
+ offset
244 # -----------------------------------------------------------------------------
247 def get_input_nodes(node
, links
):
248 """Get nodes that are a inputs to the given node"""
249 # Get all links going to node.
250 input_links
= {lnk
for lnk
in links
if lnk
.to_node
== node
}
251 # Sort those links, get their input nodes (and avoid doubles!).
254 for socket
in node
.inputs
:
256 for link
in input_links
:
259 # Node already treated!
261 elif link
.to_socket
== socket
:
262 sorted_nodes
.append(nd
)
265 input_links
-= done_links
269 def auto_align_nodes(node_tree
):
270 """Given a shader node tree, arrange nodes neatly relative to the output node."""
273 nodes
= node_tree
.nodes
274 links
= node_tree
.links
277 if node
.type == 'OUTPUT_MATERIAL' or node
.type == 'GROUP_OUTPUT':
281 else: # Just in case there is no output
285 from_nodes
= get_input_nodes(to_node
, links
)
286 for i
, node
in enumerate(from_nodes
):
287 node
.location
.x
= min(node
.location
.x
, to_node
.location
.x
- x_gap
)
288 node
.location
.y
= to_node
.location
.y
289 node
.location
.y
-= i
* y_gap
290 node
.location
.y
+= (len(from_nodes
) - 1) * y_gap
/ (len(from_nodes
))
296 def clean_node_tree(node_tree
):
297 """Clear all nodes in a shader node tree except the output.
299 Returns the output node
301 nodes
= node_tree
.nodes
302 for node
in list(nodes
): # copy to avoid altering the loop's data source
303 if not node
.type == 'OUTPUT_MATERIAL':
306 return node_tree
.nodes
[0]
309 def get_shadeless_node(dest_node_tree
):
310 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
312 node_tree
= bpy
.data
.node_groups
['IAP_SHADELESS']
315 # need to build node shadeless node group
316 node_tree
= bpy
.data
.node_groups
.new('IAP_SHADELESS', 'ShaderNodeTree')
317 output_node
= node_tree
.nodes
.new('NodeGroupOutput')
318 input_node
= node_tree
.nodes
.new('NodeGroupInput')
320 node_tree
.outputs
.new('NodeSocketShader', 'Shader')
321 node_tree
.inputs
.new('NodeSocketColor', 'Color')
323 # This could be faster as a transparent shader, but then no ambient occlusion
324 diffuse_shader
= node_tree
.nodes
.new('ShaderNodeBsdfDiffuse')
325 node_tree
.links
.new(diffuse_shader
.inputs
[0], input_node
.outputs
[0])
327 emission_shader
= node_tree
.nodes
.new('ShaderNodeEmission')
328 node_tree
.links
.new(emission_shader
.inputs
[0], input_node
.outputs
[0])
330 light_path
= node_tree
.nodes
.new('ShaderNodeLightPath')
331 is_glossy_ray
= light_path
.outputs
['Is Glossy Ray']
332 is_shadow_ray
= light_path
.outputs
['Is Shadow Ray']
333 ray_depth
= light_path
.outputs
['Ray Depth']
334 transmission_depth
= light_path
.outputs
['Transmission Depth']
336 unrefracted_depth
= node_tree
.nodes
.new('ShaderNodeMath')
337 unrefracted_depth
.operation
= 'SUBTRACT'
338 unrefracted_depth
.label
= 'Bounce Count'
339 node_tree
.links
.new(unrefracted_depth
.inputs
[0], ray_depth
)
340 node_tree
.links
.new(unrefracted_depth
.inputs
[1], transmission_depth
)
342 refracted
= node_tree
.nodes
.new('ShaderNodeMath')
343 refracted
.operation
= 'SUBTRACT'
344 refracted
.label
= 'Camera or Refracted'
345 refracted
.inputs
[0].default_value
= 1.0
346 node_tree
.links
.new(refracted
.inputs
[1], unrefracted_depth
.outputs
[0])
348 reflection_limit
= node_tree
.nodes
.new('ShaderNodeMath')
349 reflection_limit
.operation
= 'SUBTRACT'
350 reflection_limit
.label
= 'Limit Reflections'
351 reflection_limit
.inputs
[0].default_value
= 2.0
352 node_tree
.links
.new(reflection_limit
.inputs
[1], ray_depth
)
354 camera_reflected
= node_tree
.nodes
.new('ShaderNodeMath')
355 camera_reflected
.operation
= 'MULTIPLY'
356 camera_reflected
.label
= 'Camera Ray to Glossy'
357 node_tree
.links
.new(camera_reflected
.inputs
[0], reflection_limit
.outputs
[0])
358 node_tree
.links
.new(camera_reflected
.inputs
[1], is_glossy_ray
)
360 shadow_or_reflect
= node_tree
.nodes
.new('ShaderNodeMath')
361 shadow_or_reflect
.operation
= 'MAXIMUM'
362 shadow_or_reflect
.label
= 'Shadow or Reflection?'
363 node_tree
.links
.new(shadow_or_reflect
.inputs
[0], camera_reflected
.outputs
[0])
364 node_tree
.links
.new(shadow_or_reflect
.inputs
[1], is_shadow_ray
)
366 shadow_or_reflect_or_refract
= node_tree
.nodes
.new('ShaderNodeMath')
367 shadow_or_reflect_or_refract
.operation
= 'MAXIMUM'
368 shadow_or_reflect_or_refract
.label
= 'Shadow, Reflect or Refract?'
369 node_tree
.links
.new(shadow_or_reflect_or_refract
.inputs
[0], shadow_or_reflect
.outputs
[0])
370 node_tree
.links
.new(shadow_or_reflect_or_refract
.inputs
[1], refracted
.outputs
[0])
372 mix_shader
= node_tree
.nodes
.new('ShaderNodeMixShader')
373 node_tree
.links
.new(mix_shader
.inputs
[0], shadow_or_reflect_or_refract
.outputs
[0])
374 node_tree
.links
.new(mix_shader
.inputs
[1], diffuse_shader
.outputs
[0])
375 node_tree
.links
.new(mix_shader
.inputs
[2], emission_shader
.outputs
[0])
377 node_tree
.links
.new(output_node
.inputs
[0], mix_shader
.outputs
[0])
379 auto_align_nodes(node_tree
)
381 group_node
= dest_node_tree
.nodes
.new("ShaderNodeGroup")
382 group_node
.node_tree
= node_tree
387 # -----------------------------------------------------------------------------
388 # Corner Pin Driver Helpers
390 @bpy.app
.handlers
.persistent
391 def check_drivers(*args
, **kwargs
):
392 """Check if watched objects in a scene have changed and trigger compositor update
394 This is part of a hack to ensure the compositor updates
395 itself when the objects used for drivers change.
397 It only triggers if transformation matricies change to avoid
398 a cyclic loop of updates.
400 if not watched_objects
:
401 # if there is nothing to watch, don't bother running this
402 bpy
.app
.handlers
.depsgraph_update_post
.remove(check_drivers
)
406 for name
, matrix
in list(watched_objects
.items()):
408 obj
= bpy
.data
.objects
[name
]
410 # The user must have removed this object
411 del watched_objects
[name
]
413 new_matrix
= tuple(map(tuple, obj
.matrix_world
)).__hash
__()
414 if new_matrix
!= matrix
:
415 watched_objects
[name
] = new_matrix
419 # Trick to re-evaluate drivers
420 bpy
.context
.scene
.frame_current
= bpy
.context
.scene
.frame_current
423 def register_watched_object(obj
):
424 """Register an object to be monitored for transformation changes"""
427 # known object? -> we're done
428 if name
in watched_objects
:
431 if not watched_objects
:
432 # make sure check_drivers is active
433 bpy
.app
.handlers
.depsgraph_update_post
.append(check_drivers
)
435 watched_objects
[name
] = None
438 def find_plane_corner(object_name
, x
, y
, axis
, camera
=None, *args
, **kwargs
):
439 """Find the location in camera space of a plane's corner"""
441 # I've added args / kwargs as a compatibility measure with future versions
442 warnings
.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
444 plane
= bpy
.data
.objects
[object_name
]
446 # Passing in camera doesn't work before 2.78, so we use the current one
447 camera
= camera
or bpy
.context
.scene
.camera
449 # Hack to ensure compositor updates on future changes
450 register_watched_object(camera
)
451 register_watched_object(plane
)
453 scale
= plane
.scale
* 2.0
454 v
= plane
.dimensions
.copy()
457 v
= plane
.matrix_world
@ v
459 camera_vertex
= world_to_camera_view(
460 bpy
.context
.scene
, camera
, v
)
462 return camera_vertex
[axis
]
465 @bpy.app
.handlers
.persistent
466 def register_driver(*args
, **kwargs
):
467 """Register the find_plane_corner function for use with drivers"""
468 bpy
.app
.driver_namespace
['import_image__find_plane_corner'] = find_plane_corner
471 # -----------------------------------------------------------------------------
472 # Compositing Helpers
474 def group_in_frame(node_tree
, name
, nodes
):
475 frame_node
= node_tree
.nodes
.new("NodeFrame")
476 frame_node
.label
= name
477 frame_node
.name
= name
+ "_frame"
479 min_pos
= Vector(nodes
[0].location
)
480 max_pos
= min_pos
.copy()
483 top_left
= node
.location
484 bottom_right
= top_left
+ Vector((node
.width
, -node
.height
))
487 min_pos
[i
] = min(min_pos
[i
], top_left
[i
], bottom_right
[i
])
488 max_pos
[i
] = max(max_pos
[i
], top_left
[i
], bottom_right
[i
])
490 node
.parent
= frame_node
492 frame_node
.width
= max_pos
[0] - min_pos
[0] + 50
493 frame_node
.height
= max(max_pos
[1] - min_pos
[1] + 50, 450)
494 frame_node
.shrink
= True
499 def position_frame_bottom_left(node_tree
, frame_node
):
500 newpos
= Vector((100000, 100000)) # start reasonably far top / right
502 # Align with the furthest left
503 for node
in node_tree
.nodes
.values():
504 if node
!= frame_node
and node
.parent
!= frame_node
:
505 newpos
.x
= min(newpos
.x
, node
.location
.x
+ 30)
507 # As high as we can get without overlapping anything to the right
508 for node
in node_tree
.nodes
.values():
509 if node
!= frame_node
and not node
.parent
:
510 if node
.location
.x
< newpos
.x
+ frame_node
.width
:
511 print("Below", node
.name
, node
.location
, node
.height
, node
.dimensions
)
512 newpos
.y
= min(newpos
.y
, node
.location
.y
- max(node
.dimensions
.y
, node
.height
) - 20)
514 frame_node
.location
= newpos
517 def setup_compositing(context
, plane
, img_spec
):
518 # Node Groups only work with "new" dependency graph and even
519 # then it has some problems with not updating the first time
520 # So instead this groups with a node frame, which works reliably
522 scene
= context
.scene
523 scene
.use_nodes
= True
524 node_tree
= scene
.node_tree
527 image_node
= node_tree
.nodes
.new("CompositorNodeImage")
528 image_node
.name
= name
+ "_image"
529 image_node
.image
= img_spec
.image
530 image_node
.location
= Vector((0, 0))
531 image_node
.frame_start
= img_spec
.frame_start
532 image_node
.frame_offset
= img_spec
.frame_offset
533 image_node
.frame_duration
= img_spec
.frame_duration
535 scale_node
= node_tree
.nodes
.new("CompositorNodeScale")
536 scale_node
.name
= name
+ "_scale"
537 scale_node
.space
= 'RENDER_SIZE'
538 scale_node
.location
= image_node
.location
+ \
539 Vector((image_node
.width
+ 20, 0))
540 scale_node
.show_options
= False
542 cornerpin_node
= node_tree
.nodes
.new("CompositorNodeCornerPin")
543 cornerpin_node
.name
= name
+ "_cornerpin"
544 cornerpin_node
.location
= scale_node
.location
+ \
545 Vector((0, -scale_node
.height
))
547 node_tree
.links
.new(scale_node
.inputs
[0], image_node
.outputs
[0])
548 node_tree
.links
.new(cornerpin_node
.inputs
[0], scale_node
.outputs
[0])
550 # Put all the nodes in a frame for organization
551 frame_node
= group_in_frame(
553 (image_node
, scale_node
, cornerpin_node
)
556 # Position frame at bottom / left
557 position_frame_bottom_left(node_tree
, frame_node
)
560 for corner
in cornerpin_node
.inputs
[1:]:
561 id = corner
.identifier
562 x
= -1 if 'Left' in id else 1
563 y
= -1 if 'Lower' in id else 1
564 drivers
= corner
.driver_add('default_value')
565 for i
, axis_fcurve
in enumerate(drivers
):
566 driver
= axis_fcurve
.driver
567 # Always use the current camera
568 add_driver_prop(driver
, 'camera', 'SCENE', scene
, 'camera')
569 # Track camera location to ensure Deps Graph triggers (not used in the call)
570 add_driver_prop(driver
, 'cam_loc_x', 'OBJECT', scene
.camera
, 'location[0]')
571 # Don't break if the name changes
572 add_driver_prop(driver
, 'name', 'OBJECT', plane
, 'name')
573 driver
.expression
= "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
577 driver
.type = 'SCRIPTED'
578 driver
.is_valid
= True
579 axis_fcurve
.is_valid
= True
580 driver
.expression
= "%s" % driver
.expression
582 context
.view_layer
.update()
585 # -----------------------------------------------------------------------------
588 class IMPORT_IMAGE_OT_to_plane(Operator
, AddObjectHelper
):
589 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
591 bl_idname
= "import_image.to_plane"
592 bl_label
= "Import Images as Planes"
593 bl_options
= {'REGISTER', 'PRESET', 'UNDO'}
595 # ----------------------
596 # File dialog properties
597 files
: CollectionProperty(type=bpy
.types
.OperatorFileListElement
, options
={'HIDDEN', 'SKIP_SAVE'})
599 directory
: StringProperty(maxlen
=1024, subtype
='FILE_PATH', options
={'HIDDEN', 'SKIP_SAVE'})
601 filter_image
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
602 filter_movie
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
603 filter_folder
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
605 # ----------------------
606 # Properties - Importing
607 force_reload
: BoolProperty(
608 name
="Force Reload", default
=False,
609 description
="Force reloading of the image if already opened elsewhere in Blender"
612 image_sequence
: BoolProperty(
613 name
="Animate Image Sequences", default
=False,
614 description
="Import sequentially numbered images as an animated "
615 "image sequence instead of separate planes"
618 # -------------------------------------
619 # Properties - Position and Orientation
620 axis_id_to_vector
= {
621 'X+': Vector(( 1, 0, 0)),
622 'Y+': Vector(( 0, 1, 0)),
623 'Z+': Vector(( 0, 0, 1)),
624 'X-': Vector((-1, 0, 0)),
625 'Y-': Vector(( 0, -1, 0)),
626 'Z-': Vector(( 0, 0, -1)),
629 offset
: BoolProperty(name
="Offset Planes", default
=True, description
="Offset Planes From Each Other")
632 ('X+', "X+", "Side by Side to the Left"),
633 ('Y+', "Y+", "Side by Side, Downward"),
634 ('Z+', "Z+", "Stacked Above"),
635 ('X-', "X-", "Side by Side to the Right"),
636 ('Y-', "Y-", "Side by Side, Upward"),
637 ('Z-', "Z-", "Stacked Below"),
639 offset_axis
: EnumProperty(
640 name
="Orientation", default
='X+', items
=OFFSET_MODES
,
641 description
="How planes are oriented relative to each others' local axis"
644 offset_amount
: FloatProperty(
645 name
="Offset", soft_min
=0, default
=0.1, description
="Space between planes",
646 subtype
='DISTANCE', unit
='LENGTH'
650 ('X+', "X+", "Facing Positive X"),
651 ('Y+', "Y+", "Facing Positive Y"),
652 ('Z+', "Z+ (Up)", "Facing Positive Z"),
653 ('X-', "X-", "Facing Negative X"),
654 ('Y-', "Y-", "Facing Negative Y"),
655 ('Z-', "Z- (Down)", "Facing Negative Z"),
656 ('CAM', "Face Camera", "Facing Camera"),
657 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
659 align_axis
: EnumProperty(
660 name
="Align", default
='CAM_AX', items
=AXIS_MODES
,
661 description
="How to align the planes"
663 # prev_align_axis is used only by update_size_model
664 prev_align_axis
: EnumProperty(
665 items
=AXIS_MODES
+ (('NONE', '', ''),), default
='NONE', options
={'HIDDEN', 'SKIP_SAVE'})
666 align_track
: BoolProperty(
667 name
="Track Camera", default
=False, description
="Always face the camera"
672 def update_size_mode(self
, context
):
673 """If sizing relative to the camera, always face the camera"""
674 if self
.size_mode
== 'CAMERA':
675 self
.prev_align_axis
= self
.align_axis
676 self
.align_axis
= 'CAM'
678 # if a different alignment was set revert to that when
679 # size mode is changed
680 if self
.prev_align_axis
!= 'NONE':
681 self
.align_axis
= self
.prev_align_axis
682 self
._prev
_align
_axis
= 'NONE'
685 ('ABSOLUTE', "Absolute", "Use absolute size"),
686 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
687 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
688 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
690 size_mode
: EnumProperty(
691 name
="Size Mode", default
='ABSOLUTE', items
=SIZE_MODES
,
692 update
=update_size_mode
,
693 description
="How the size of the plane is computed")
696 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
697 ('FIT', "Fit", "Fit entire image within the camera frame"),
699 fill_mode
: EnumProperty(name
="Scale", default
='FILL', items
=FILL_MODES
,
700 description
="How large in the camera frame is the plane")
702 height
: FloatProperty(name
="Height", description
="Height of the created plane",
703 default
=1.0, min=0.001, soft_min
=0.001, subtype
='DISTANCE', unit
='LENGTH')
705 factor
: FloatProperty(name
="Definition", min=1.0, default
=600.0,
706 description
="Number of pixels per inch or Blender Unit")
708 # ------------------------------
709 # Properties - Material / Shader
711 ('PRINCIPLED',"Principled","Principled Shader"),
712 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
713 ('EMISSION', "Emit", "Emission Shader"),
715 shader
: EnumProperty(name
="Shader", items
=SHADERS
, default
='PRINCIPLED', description
="Node shader to use")
717 emit_strength
: FloatProperty(
718 name
="Strength", min=0.0, default
=1.0, soft_max
=10.0,
719 step
=100, description
="Brightness of Emission Texture")
721 overwrite_material
: BoolProperty(
722 name
="Overwrite Material", default
=True,
723 description
="Overwrite existing Material (based on material name)")
725 compositing_nodes
: BoolProperty(
726 name
="Setup Corner Pin", default
=False,
727 description
="Build Compositor Nodes to reference this image "
728 "without re-rendering")
732 use_transparency
: BoolProperty(
733 name
="Use Alpha", default
=True,
734 description
="Use alpha channel for transparency")
736 t
= bpy
.types
.Image
.bl_rna
.properties
["alpha_mode"]
737 alpha_mode_items
= tuple((e
.identifier
, e
.name
, e
.description
) for e
in t
.enum_items
)
738 alpha_mode
: EnumProperty(
739 name
=t
.name
, items
=alpha_mode_items
, default
=t
.default
,
740 description
=t
.description
)
742 t
= bpy
.types
.ImageUser
.bl_rna
.properties
["use_auto_refresh"]
743 use_auto_refresh
: BoolProperty(name
=t
.name
, default
=True, description
=t
.description
)
745 relative
: BoolProperty(name
="Relative Paths", default
=True, description
="Use relative file paths")
749 def draw_import_config(self
, context
):
750 # --- Import Options --- #
754 box
.label(text
="Import Options:", icon
='IMPORT')
756 row
.active
= bpy
.data
.is_saved
757 row
.prop(self
, "relative")
759 box
.prop(self
, "force_reload")
760 box
.prop(self
, "image_sequence")
762 def draw_material_config(self
, context
):
763 # --- Material / Rendering Properties --- #
767 box
.label(text
="Compositing Nodes:", icon
='RENDERLAYERS')
768 box
.prop(self
, "compositing_nodes")
770 box
.label(text
="Material Settings:", icon
='MATERIAL')
773 row
.prop(self
, 'shader', expand
=True)
774 if self
.shader
== 'EMISSION':
775 box
.prop(self
, "emit_strength")
777 engine
= context
.scene
.render
.engine
778 if engine
not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
779 box
.label(text
="%s is not supported" % engine
, icon
='ERROR')
781 box
.prop(self
, "overwrite_material")
783 box
.label(text
="Texture Settings:", icon
='TEXTURE')
785 row
.prop(self
, "use_transparency")
787 sub
.active
= self
.use_transparency
788 sub
.prop(self
, "alpha_mode", text
="")
789 box
.prop(self
, "use_auto_refresh")
791 def draw_spatial_config(self
, context
):
792 # --- Spatial Properties: Position, Size and Orientation --- #
796 box
.label(text
="Position:", icon
='SNAP_GRID')
797 box
.prop(self
, "offset")
800 row
.prop(self
, "offset_axis", expand
=True)
802 row
.prop(self
, "offset_amount")
803 col
.enabled
= self
.offset
805 box
.label(text
="Plane dimensions:", icon
='ARROW_LEFTRIGHT')
807 row
.prop(self
, "size_mode", expand
=True)
808 if self
.size_mode
== 'ABSOLUTE':
809 box
.prop(self
, "height")
810 elif self
.size_mode
== 'CAMERA':
812 row
.prop(self
, "fill_mode", expand
=True)
814 box
.prop(self
, "factor")
816 box
.label(text
="Orientation:")
818 row
.enabled
= 'CAM' not in self
.size_mode
819 row
.prop(self
, "align_axis")
821 row
.enabled
= 'CAM' in self
.align_axis
822 row
.alignment
= 'RIGHT'
823 row
.prop(self
, "align_track")
825 def draw(self
, context
):
827 # Draw configuration sections
828 self
.draw_import_config(context
)
829 self
.draw_material_config(context
)
830 self
.draw_spatial_config(context
)
832 # -------------------------------------------------------------------------
834 def invoke(self
, context
, event
):
835 engine
= context
.scene
.render
.engine
836 if engine
not in {'CYCLES', 'BLENDER_EEVEE'}:
837 if engine
!= 'BLENDER_WORKBENCH':
838 self
.report({'ERROR'}, "Cannot generate materials for unknown %s render engine" % engine
)
841 self
.report({'WARNING'},
842 "Generating Cycles/EEVEE compatible material, but won't be visible with %s engine" % engine
)
845 context
.window_manager
.fileselect_add(self
)
846 return {'RUNNING_MODAL'}
848 def execute(self
, context
):
849 if not bpy
.data
.is_saved
:
850 self
.relative
= False
852 # this won't work in edit mode
853 editmode
= context
.preferences
.edit
.use_enter_edit_mode
854 context
.preferences
.edit
.use_enter_edit_mode
= False
855 if context
.active_object
and context
.active_object
.mode
!= 'OBJECT':
856 bpy
.ops
.object.mode_set(mode
='OBJECT')
858 self
.import_images(context
)
860 context
.preferences
.edit
.use_enter_edit_mode
= editmode
864 def import_images(self
, context
):
866 # load images / sequences
867 images
= tuple(load_images(
868 (fn
.name
for fn
in self
.files
),
870 force_reload
=self
.force_reload
,
871 find_sequences
=self
.image_sequence
874 # Create individual planes
875 planes
= [self
.single_image_spec_to_plane(context
, img_spec
) for img_spec
in images
]
877 context
.view_layer
.update()
879 # Align planes relative to each other
881 offset_axis
= self
.axis_id_to_vector
[self
.offset_axis
]
882 offset_planes(planes
, self
.offset_amount
, offset_axis
)
884 if self
.size_mode
== 'CAMERA' and offset_axis
.z
:
886 x
, y
= compute_camera_size(
887 context
, plane
.location
,
888 self
.fill_mode
, plane
.dimensions
.x
/ plane
.dimensions
.y
)
889 plane
.dimensions
= x
, y
, 0.0
891 # setup new selection
893 plane
.select_set(True)
896 self
.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes
)))
898 # operate on a single image
899 def single_image_spec_to_plane(self
, context
, img_spec
):
902 self
.apply_image_options(img_spec
.image
)
905 engine
= context
.scene
.render
.engine
906 if engine
in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
907 material
= self
.create_cycles_material(context
, img_spec
)
909 # Create and position plane object
910 plane
= self
.create_image_plane(context
, material
.name
, img_spec
)
913 plane
.data
.materials
.append(material
)
915 # If applicable, setup Corner Pin node
916 if self
.compositing_nodes
:
917 setup_compositing(context
, plane
, img_spec
)
921 def apply_image_options(self
, image
):
922 if self
.use_transparency
== False:
923 image
.alpha_mode
= 'NONE'
925 image
.alpha_mode
= self
.alpha_mode
928 try: # can't always find the relative path (between drive letters on windows)
929 image
.filepath
= bpy
.path
.relpath(image
.filepath
)
933 def apply_texture_options(self
, texture
, img_spec
):
934 # Shared by both Cycles and Blender Internal
935 image_user
= texture
.image_user
936 image_user
.use_auto_refresh
= self
.use_auto_refresh
937 image_user
.frame_start
= img_spec
.frame_start
938 image_user
.frame_offset
= img_spec
.frame_offset
939 image_user
.frame_duration
= img_spec
.frame_duration
941 # Image sequences need auto refresh to display reliably
942 if img_spec
.image
.source
== 'SEQUENCE':
943 image_user
.use_auto_refresh
= True
945 texture
.extension
= 'CLIP' # Default of "Repeat" can cause artifacts
947 def apply_material_options(self
, material
, slot
):
950 if self
.use_transparency
:
952 material
.specular_alpha
= 0.0
953 slot
.use_map_alpha
= True
956 material
.specular_alpha
= 1.0
957 slot
.use_map_alpha
= False
959 material
.specular_intensity
= 0
960 material
.diffuse_intensity
= 1.0
961 material
.use_transparency
= self
.use_transparency
962 material
.transparency_method
= 'Z_TRANSPARENCY'
963 material
.use_shadeless
= (shader
== 'SHADELESS')
964 material
.use_transparent_shadows
= (shader
== 'DIFFUSE')
965 material
.emit
= self
.emit_strength
if shader
== 'EMISSION' else 0.0
967 # -------------------------------------------------------------------------
969 def create_cycles_texnode(self
, context
, node_tree
, img_spec
):
970 tex_image
= node_tree
.nodes
.new('ShaderNodeTexImage')
971 tex_image
.image
= img_spec
.image
972 tex_image
.show_texture
= True
973 self
.apply_texture_options(tex_image
, img_spec
)
976 def create_cycles_material(self
, context
, img_spec
):
977 image
= img_spec
.image
978 name_compat
= bpy
.path
.display_name_from_filepath(image
.filepath
)
980 if self
.overwrite_material
:
981 for mat
in bpy
.data
.materials
:
982 if mat
.name
== name_compat
:
985 material
= bpy
.data
.materials
.new(name
=name_compat
)
987 material
.use_nodes
= True
988 if self
.use_transparency
:
989 material
.blend_method
= 'BLEND'
990 node_tree
= material
.node_tree
991 out_node
= clean_node_tree(node_tree
)
993 tex_image
= self
.create_cycles_texnode(context
, node_tree
, img_spec
)
995 if self
.shader
== 'PRINCIPLED':
996 core_shader
= node_tree
.nodes
.new('ShaderNodeBsdfPrincipled')
997 elif self
.shader
== 'SHADELESS':
998 core_shader
= get_shadeless_node(node_tree
)
999 elif self
.shader
== 'EMISSION':
1000 core_shader
= node_tree
.nodes
.new('ShaderNodeBsdfPrincipled')
1001 core_shader
.inputs
['Emission Strength'].default_value
= self
.emit_strength
1002 core_shader
.inputs
['Base Color'].default_value
= (0.0, 0.0, 0.0, 1.0)
1003 core_shader
.inputs
['Specular'].default_value
= 0.0
1005 # Connect color from texture
1006 if self
.shader
in {'PRINCIPLED', 'SHADELESS'}:
1007 node_tree
.links
.new(core_shader
.inputs
[0], tex_image
.outputs
['Color'])
1008 elif self
.shader
== 'EMISSION':
1009 node_tree
.links
.new(core_shader
.inputs
['Emission'], tex_image
.outputs
['Color'])
1011 if self
.use_transparency
:
1012 if self
.shader
in {'PRINCIPLED', 'EMISSION'}:
1013 node_tree
.links
.new(core_shader
.inputs
['Alpha'], tex_image
.outputs
['Alpha'])
1015 bsdf_transparent
= node_tree
.nodes
.new('ShaderNodeBsdfTransparent')
1017 mix_shader
= node_tree
.nodes
.new('ShaderNodeMixShader')
1018 node_tree
.links
.new(mix_shader
.inputs
['Fac'], tex_image
.outputs
['Alpha'])
1019 node_tree
.links
.new(mix_shader
.inputs
[1], bsdf_transparent
.outputs
['BSDF'])
1020 node_tree
.links
.new(mix_shader
.inputs
[2], core_shader
.outputs
[0])
1021 core_shader
= mix_shader
1023 node_tree
.links
.new(out_node
.inputs
['Surface'], core_shader
.outputs
[0])
1025 auto_align_nodes(node_tree
)
1028 # -------------------------------------------------------------------------
1030 def create_image_plane(self
, context
, name
, img_spec
):
1032 width
, height
= self
.compute_plane_size(context
, img_spec
)
1035 bpy
.ops
.mesh
.primitive_plane_add('INVOKE_REGION_WIN')
1036 plane
= context
.active_object
1037 # Why does mesh.primitive_plane_add leave the object in edit mode???
1038 if plane
.mode
!= 'OBJECT':
1039 bpy
.ops
.object.mode_set(mode
='OBJECT')
1040 plane
.dimensions
= width
, height
, 0.0
1041 plane
.data
.name
= plane
.name
= name
1042 bpy
.ops
.object.transform_apply(location
=False, rotation
=False, scale
=True)
1044 # If sizing for camera, also insert into the camera's field of view
1045 if self
.size_mode
== 'CAMERA':
1046 offset_axis
= self
.axis_id_to_vector
[self
.offset_axis
]
1047 translate_axis
= [0 if offset_axis
[i
] else 1 for i
in (0, 1)]
1048 center_in_camera(context
.scene
, context
.scene
.camera
, plane
, translate_axis
)
1050 self
.align_plane(context
, plane
)
1054 def compute_plane_size(self
, context
, img_spec
):
1055 """Given the image size in pixels and location, determine size of plane"""
1056 px
, py
= img_spec
.size
1059 if px
== 0 or py
== 0:
1062 if self
.size_mode
== 'ABSOLUTE':
1066 elif self
.size_mode
== 'CAMERA':
1067 x
, y
= compute_camera_size(
1068 context
, context
.scene
.cursor
.location
,
1069 self
.fill_mode
, px
/ py
1072 elif self
.size_mode
== 'DPI':
1073 fact
= 1 / self
.factor
/ context
.scene
.unit_settings
.scale_length
* 0.0254
1077 else: # elif self.size_mode == 'DPBU'
1078 fact
= 1 / self
.factor
1084 def align_plane(self
, context
, plane
):
1085 """Pick an axis and align the plane to it"""
1086 if 'CAM' in self
.align_axis
:
1088 camera
= context
.scene
.camera
1090 # Find the axis that best corresponds to the camera's view direction
1091 axis
= camera
.matrix_world
@ \
1092 Vector((0, 0, 1)) - camera
.matrix_world
.col
[3].xyz
1093 # pick the axis with the greatest magnitude
1094 mag
= max(map(abs, axis
))
1095 # And use that axis & direction
1097 n
/ mag
if abs(n
) == mag
else 0.0
1101 # No camera? Just face Z axis
1102 axis
= Vector((0, 0, 1))
1103 self
.align_axis
= 'Z+'
1106 axis
= self
.axis_id_to_vector
[self
.align_axis
]
1108 # rotate accordingly for x/y axiis
1110 plane
.rotation_euler
.x
= pi
/ 2
1113 plane
.rotation_euler
.z
= pi
1115 plane
.rotation_euler
.z
= 0
1117 plane
.rotation_euler
.z
= pi
/ 2
1119 plane
.rotation_euler
.z
= -pi
/ 2
1121 # or flip 180 degrees for negative z
1123 plane
.rotation_euler
.y
= pi
1125 if self
.align_axis
== 'CAM':
1126 constraint
= plane
.constraints
.new('COPY_ROTATION')
1127 constraint
.target
= camera
1128 constraint
.use_x
= constraint
.use_y
= constraint
.use_z
= True
1129 if not self
.align_track
:
1130 bpy
.ops
.object.visual_transform_apply()
1131 plane
.constraints
.clear()
1133 if self
.align_axis
== 'CAM_AX' and self
.align_track
:
1134 constraint
= plane
.constraints
.new('LOCKED_TRACK')
1135 constraint
.target
= camera
1136 constraint
.track_axis
= 'TRACK_Z'
1137 constraint
.lock_axis
= 'LOCK_Y'
1140 # -----------------------------------------------------------------------------
1143 def import_images_button(self
, context
):
1144 self
.layout
.operator(IMPORT_IMAGE_OT_to_plane
.bl_idname
, text
="Images as Planes", icon
='TEXTURE')
1148 IMPORT_IMAGE_OT_to_plane
,
1154 bpy
.utils
.register_class(cls
)
1156 bpy
.types
.TOPBAR_MT_file_import
.append(import_images_button
)
1157 bpy
.types
.VIEW3D_MT_image_add
.append(import_images_button
)
1159 bpy
.app
.handlers
.load_post
.append(register_driver
)
1164 bpy
.types
.TOPBAR_MT_file_import
.remove(import_images_button
)
1165 bpy
.types
.VIEW3D_MT_image_add
.remove(import_images_button
)
1167 # This will only exist if drivers are active
1168 if check_drivers
in bpy
.app
.handlers
.depsgraph_update_post
:
1169 bpy
.app
.handlers
.depsgraph_update_post
.remove(check_drivers
)
1171 bpy
.app
.handlers
.load_post
.remove(register_driver
)
1172 del bpy
.app
.driver_namespace
['import_image__find_plane_corner']
1175 bpy
.utils
.unregister_class(cls
)
1178 if __name__
== "__main__":
1179 # Run simple doc tests