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), mrbimax",
8 "location": "File > Import > Images as Planes or Add > Image > 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 bpy
.app
.translations
import pgettext_tip
as tip_
27 from mathutils
import Vector
29 from bpy
.props
import (
37 from bpy_extras
.object_utils
import (
42 from bpy_extras
.image_utils
import load_image
44 # -----------------------------------------------------------------------------
45 # Module-level Shared State
47 watched_objects
= {} # used to trigger compositor updates on scene updates
50 # -----------------------------------------------------------------------------
53 def add_driver_prop(driver
, name
, type, id, path
):
54 """Configure a new driver variable."""
55 dv
= driver
.variables
.new()
57 dv
.type = 'SINGLE_PROP'
58 target
= dv
.targets
[0]
61 target
.data_path
= path
64 # -----------------------------------------------------------------------------
67 ImageSpec
= namedtuple(
69 ['image', 'size', 'frame_start', 'frame_offset', 'frame_duration'])
71 num_regex
= re
.compile('[0-9]') # Find a single number
72 nums_regex
= re
.compile('[0-9]+') # Find a set of numbers
75 def find_image_sequences(files
):
76 """From a group of files, detect image sequences.
78 This returns a generator of tuples, which contain the filename,
79 start frame, and length of the detected sequence
81 >>> list(find_image_sequences([
82 ... "test2-001.jp2", "test2-002.jp2",
83 ... "test3-003.jp2", "test3-004.jp2", "test3-005.jp2", "test3-006.jp2",
85 [('blaah', 1, 1), ('test2-001.jp2', 1, 2), ('test3-003.jp2', 3, 4)]
88 files
= iter(sorted(files
))
94 for filename
in files
:
95 new_pattern
= num_regex
.sub('#', filename
)
96 new_matches
= list(map(int, nums_regex
.findall(filename
)))
97 if new_pattern
== pattern
:
98 # this file looks like it may be in sequence from the previous
100 # if there are multiple sets of numbers, figure out what changed
102 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
107 # did it only change by one?
108 for i
, prev
, cur
in zip(count(), matches
, new_matches
):
110 # We expect this to increment
120 # No continuation -> spit out what we found and reset counters
123 yield prev_file
, matches
[segment
], length
125 yield prev_file
, 1, 1
128 matches
= new_matches
129 pattern
= new_pattern
135 yield prev_file
, matches
[segment
], length
137 yield prev_file
, 1, 1
140 def load_images(filenames
, directory
, force_reload
=False, frame_start
=1, find_sequences
=False):
141 """Wrapper for bpy's load_image
143 Loads a set of images, movies, or even image sequences
144 Returns a generator of ImageSpec wrapper objects later used for texture setup
146 if find_sequences
: # if finding sequences, we need some pre-processing first
147 file_iter
= find_image_sequences(filenames
)
149 file_iter
= zip(filenames
, repeat(1), repeat(1))
151 for filename
, offset
, frames
in file_iter
:
152 image
= load_image(filename
, directory
, check_existing
=True, force_reload
=force_reload
)
154 # Size is unavailable for sequences, so we grab it early
155 size
= tuple(image
.size
)
157 if image
.source
== 'MOVIE':
159 # This number is only valid when read a second time in 2.77
160 # This repeated line is not a mistake
161 frames
= image
.frame_duration
162 frames
= image
.frame_duration
164 elif frames
> 1: # Not movie, but multiple frames -> image sequence
165 image
.source
= 'SEQUENCE'
167 yield ImageSpec(image
, size
, frame_start
, offset
- 1, frames
)
170 # -----------------------------------------------------------------------------
171 # Position & Size Helpers
173 def offset_planes(planes
, gap
, axis
):
174 """Offset planes from each other by `gap` amount along a _local_ vector `axis`
176 For example, offset_planes([obj1, obj2], 0.5, Vector(0, 0, 1)) will place
177 obj2 0.5 blender units away from obj1 along the local positive Z axis.
179 This is in local space, not world space, so all planes should share
180 a common scale and rotation.
184 for current
in planes
[1:]:
185 local_offset
= abs((prior
.dimensions
+ current
.dimensions
).dot(axis
)) / 2.0 + gap
187 offset
+= local_offset
* axis
188 current
.location
= current
.matrix_world
@ offset
193 def compute_camera_size(context
, center
, fill_mode
, aspect
):
194 """Determine how large an object needs to be to fit or fill the camera's field of view."""
195 scene
= context
.scene
196 camera
= scene
.camera
197 view_frame
= camera
.data
.view_frame(scene
=scene
)
199 Vector([max(v
[i
] for v
in view_frame
) for i
in range(3)]) - \
200 Vector([min(v
[i
] for v
in view_frame
) for i
in range(3)])
201 camera_aspect
= frame_size
.x
/ frame_size
.y
203 # Convert the frame size to the correct sizing at a given distance
204 if camera
.type == 'ORTHO':
205 frame_size
= frame_size
.xy
207 # Perspective transform
208 distance
= world_to_camera_view(scene
, camera
, center
).z
209 frame_size
= distance
* frame_size
.xy
/ (-view_frame
[0].z
)
211 # Determine what axis to match to the camera
212 match_axis
= 0 # match the Y axis size
213 match_aspect
= aspect
214 if (fill_mode
== 'FILL' and aspect
> camera_aspect
) or \
215 (fill_mode
== 'FIT' and aspect
< camera_aspect
):
216 match_axis
= 1 # match the X axis size
217 match_aspect
= 1.0 / aspect
219 # scale the other axis to the correct aspect
220 frame_size
[1 - match_axis
] = frame_size
[match_axis
] / match_aspect
225 def center_in_camera(scene
, camera
, obj
, axis
=(1, 1)):
226 """Center object along specified axis of the camera"""
227 camera_matrix_col
= camera
.matrix_world
.col
228 location
= obj
.location
230 # Vector from the camera's world coordinate center to the object's center
231 delta
= camera_matrix_col
[3].xyz
- location
233 # How far off center we are along the camera's local X
234 camera_x_mag
= delta
.dot(camera_matrix_col
[0].xyz
) * axis
[0]
235 # How far off center we are along the camera's local Y
236 camera_y_mag
= delta
.dot(camera_matrix_col
[1].xyz
) * axis
[1]
238 # Now offset only along camera local axis
239 offset
= camera_matrix_col
[0].xyz
* camera_x_mag
+ \
240 camera_matrix_col
[1].xyz
* camera_y_mag
242 obj
.location
= location
+ offset
245 # -----------------------------------------------------------------------------
248 def get_input_nodes(node
, links
):
249 """Get nodes that are a inputs to the given node"""
250 # Get all links going to node.
251 input_links
= {lnk
for lnk
in links
if lnk
.to_node
== node
}
252 # Sort those links, get their input nodes (and avoid doubles!).
255 for socket
in node
.inputs
:
257 for link
in input_links
:
260 # Node already treated!
262 elif link
.to_socket
== socket
:
263 sorted_nodes
.append(nd
)
266 input_links
-= done_links
270 def auto_align_nodes(node_tree
):
271 """Given a shader node tree, arrange nodes neatly relative to the output node."""
274 nodes
= node_tree
.nodes
275 links
= node_tree
.links
278 if node
.type == 'OUTPUT_MATERIAL' or node
.type == 'GROUP_OUTPUT':
282 else: # Just in case there is no output
286 from_nodes
= get_input_nodes(to_node
, links
)
287 for i
, node
in enumerate(from_nodes
):
288 node
.location
.x
= min(node
.location
.x
, to_node
.location
.x
- x_gap
)
289 node
.location
.y
= to_node
.location
.y
290 node
.location
.y
-= i
* y_gap
291 node
.location
.y
+= (len(from_nodes
) - 1) * y_gap
/ (len(from_nodes
))
297 def clean_node_tree(node_tree
):
298 """Clear all nodes in a shader node tree except the output.
300 Returns the output node
302 nodes
= node_tree
.nodes
303 for node
in list(nodes
): # copy to avoid altering the loop's data source
304 if not node
.type == 'OUTPUT_MATERIAL':
307 return node_tree
.nodes
[0]
310 def get_shadeless_node(dest_node_tree
):
311 """Return a "shadless" cycles/eevee node, creating a node group if nonexistent"""
313 node_tree
= bpy
.data
.node_groups
['IAP_SHADELESS']
316 # need to build node shadeless node group
317 node_tree
= bpy
.data
.node_groups
.new('IAP_SHADELESS', 'ShaderNodeTree')
318 output_node
= node_tree
.nodes
.new('NodeGroupOutput')
319 input_node
= node_tree
.nodes
.new('NodeGroupInput')
321 node_tree
.outputs
.new('NodeSocketShader', 'Shader')
322 node_tree
.inputs
.new('NodeSocketColor', 'Color')
324 # This could be faster as a transparent shader, but then no ambient occlusion
325 diffuse_shader
= node_tree
.nodes
.new('ShaderNodeBsdfDiffuse')
326 node_tree
.links
.new(diffuse_shader
.inputs
[0], input_node
.outputs
[0])
328 emission_shader
= node_tree
.nodes
.new('ShaderNodeEmission')
329 node_tree
.links
.new(emission_shader
.inputs
[0], input_node
.outputs
[0])
331 light_path
= node_tree
.nodes
.new('ShaderNodeLightPath')
332 is_glossy_ray
= light_path
.outputs
['Is Glossy Ray']
333 is_shadow_ray
= light_path
.outputs
['Is Shadow Ray']
334 ray_depth
= light_path
.outputs
['Ray Depth']
335 transmission_depth
= light_path
.outputs
['Transmission Depth']
337 unrefracted_depth
= node_tree
.nodes
.new('ShaderNodeMath')
338 unrefracted_depth
.operation
= 'SUBTRACT'
339 unrefracted_depth
.label
= 'Bounce Count'
340 node_tree
.links
.new(unrefracted_depth
.inputs
[0], ray_depth
)
341 node_tree
.links
.new(unrefracted_depth
.inputs
[1], transmission_depth
)
343 refracted
= node_tree
.nodes
.new('ShaderNodeMath')
344 refracted
.operation
= 'SUBTRACT'
345 refracted
.label
= 'Camera or Refracted'
346 refracted
.inputs
[0].default_value
= 1.0
347 node_tree
.links
.new(refracted
.inputs
[1], unrefracted_depth
.outputs
[0])
349 reflection_limit
= node_tree
.nodes
.new('ShaderNodeMath')
350 reflection_limit
.operation
= 'SUBTRACT'
351 reflection_limit
.label
= 'Limit Reflections'
352 reflection_limit
.inputs
[0].default_value
= 2.0
353 node_tree
.links
.new(reflection_limit
.inputs
[1], ray_depth
)
355 camera_reflected
= node_tree
.nodes
.new('ShaderNodeMath')
356 camera_reflected
.operation
= 'MULTIPLY'
357 camera_reflected
.label
= 'Camera Ray to Glossy'
358 node_tree
.links
.new(camera_reflected
.inputs
[0], reflection_limit
.outputs
[0])
359 node_tree
.links
.new(camera_reflected
.inputs
[1], is_glossy_ray
)
361 shadow_or_reflect
= node_tree
.nodes
.new('ShaderNodeMath')
362 shadow_or_reflect
.operation
= 'MAXIMUM'
363 shadow_or_reflect
.label
= 'Shadow or Reflection?'
364 node_tree
.links
.new(shadow_or_reflect
.inputs
[0], camera_reflected
.outputs
[0])
365 node_tree
.links
.new(shadow_or_reflect
.inputs
[1], is_shadow_ray
)
367 shadow_or_reflect_or_refract
= node_tree
.nodes
.new('ShaderNodeMath')
368 shadow_or_reflect_or_refract
.operation
= 'MAXIMUM'
369 shadow_or_reflect_or_refract
.label
= 'Shadow, Reflect or Refract?'
370 node_tree
.links
.new(shadow_or_reflect_or_refract
.inputs
[0], shadow_or_reflect
.outputs
[0])
371 node_tree
.links
.new(shadow_or_reflect_or_refract
.inputs
[1], refracted
.outputs
[0])
373 mix_shader
= node_tree
.nodes
.new('ShaderNodeMixShader')
374 node_tree
.links
.new(mix_shader
.inputs
[0], shadow_or_reflect_or_refract
.outputs
[0])
375 node_tree
.links
.new(mix_shader
.inputs
[1], diffuse_shader
.outputs
[0])
376 node_tree
.links
.new(mix_shader
.inputs
[2], emission_shader
.outputs
[0])
378 node_tree
.links
.new(output_node
.inputs
[0], mix_shader
.outputs
[0])
380 auto_align_nodes(node_tree
)
382 group_node
= dest_node_tree
.nodes
.new("ShaderNodeGroup")
383 group_node
.node_tree
= node_tree
388 # -----------------------------------------------------------------------------
389 # Corner Pin Driver Helpers
391 @bpy.app
.handlers
.persistent
392 def check_drivers(*args
, **kwargs
):
393 """Check if watched objects in a scene have changed and trigger compositor update
395 This is part of a hack to ensure the compositor updates
396 itself when the objects used for drivers change.
398 It only triggers if transformation matricies change to avoid
399 a cyclic loop of updates.
401 if not watched_objects
:
402 # if there is nothing to watch, don't bother running this
403 bpy
.app
.handlers
.depsgraph_update_post
.remove(check_drivers
)
407 for name
, matrix
in list(watched_objects
.items()):
409 obj
= bpy
.data
.objects
[name
]
411 # The user must have removed this object
412 del watched_objects
[name
]
414 new_matrix
= tuple(map(tuple, obj
.matrix_world
)).__hash
__()
415 if new_matrix
!= matrix
:
416 watched_objects
[name
] = new_matrix
420 # Trick to re-evaluate drivers
421 bpy
.context
.scene
.frame_current
= bpy
.context
.scene
.frame_current
424 def register_watched_object(obj
):
425 """Register an object to be monitored for transformation changes"""
428 # known object? -> we're done
429 if name
in watched_objects
:
432 if not watched_objects
:
433 # make sure check_drivers is active
434 bpy
.app
.handlers
.depsgraph_update_post
.append(check_drivers
)
436 watched_objects
[name
] = None
439 def find_plane_corner(object_name
, x
, y
, axis
, camera
=None, *args
, **kwargs
):
440 """Find the location in camera space of a plane's corner"""
442 # I've added args / kwargs as a compatibility measure with future versions
443 warnings
.warn("Unknown Parameters Passed to \"Images as Planes\". Maybe you need to upgrade?")
445 plane
= bpy
.data
.objects
[object_name
]
447 # Passing in camera doesn't work before 2.78, so we use the current one
448 camera
= camera
or bpy
.context
.scene
.camera
450 # Hack to ensure compositor updates on future changes
451 register_watched_object(camera
)
452 register_watched_object(plane
)
454 scale
= plane
.scale
* 2.0
455 v
= plane
.dimensions
.copy()
458 v
= plane
.matrix_world
@ v
460 camera_vertex
= world_to_camera_view(
461 bpy
.context
.scene
, camera
, v
)
463 return camera_vertex
[axis
]
466 @bpy.app
.handlers
.persistent
467 def register_driver(*args
, **kwargs
):
468 """Register the find_plane_corner function for use with drivers"""
469 bpy
.app
.driver_namespace
['import_image__find_plane_corner'] = find_plane_corner
472 # -----------------------------------------------------------------------------
473 # Compositing Helpers
475 def group_in_frame(node_tree
, name
, nodes
):
476 frame_node
= node_tree
.nodes
.new("NodeFrame")
477 frame_node
.label
= name
478 frame_node
.name
= name
+ "_frame"
480 min_pos
= Vector(nodes
[0].location
)
481 max_pos
= min_pos
.copy()
484 top_left
= node
.location
485 bottom_right
= top_left
+ Vector((node
.width
, -node
.height
))
488 min_pos
[i
] = min(min_pos
[i
], top_left
[i
], bottom_right
[i
])
489 max_pos
[i
] = max(max_pos
[i
], top_left
[i
], bottom_right
[i
])
491 node
.parent
= frame_node
493 frame_node
.width
= max_pos
[0] - min_pos
[0] + 50
494 frame_node
.height
= max(max_pos
[1] - min_pos
[1] + 50, 450)
495 frame_node
.shrink
= True
500 def position_frame_bottom_left(node_tree
, frame_node
):
501 newpos
= Vector((100000, 100000)) # start reasonably far top / right
503 # Align with the furthest left
504 for node
in node_tree
.nodes
.values():
505 if node
!= frame_node
and node
.parent
!= frame_node
:
506 newpos
.x
= min(newpos
.x
, node
.location
.x
+ 30)
508 # As high as we can get without overlapping anything to the right
509 for node
in node_tree
.nodes
.values():
510 if node
!= frame_node
and not node
.parent
:
511 if node
.location
.x
< newpos
.x
+ frame_node
.width
:
512 print("Below", node
.name
, node
.location
, node
.height
, node
.dimensions
)
513 newpos
.y
= min(newpos
.y
, node
.location
.y
- max(node
.dimensions
.y
, node
.height
) - 20)
515 frame_node
.location
= newpos
518 def setup_compositing(context
, plane
, img_spec
):
519 # Node Groups only work with "new" dependency graph and even
520 # then it has some problems with not updating the first time
521 # So instead this groups with a node frame, which works reliably
523 scene
= context
.scene
524 scene
.use_nodes
= True
525 node_tree
= scene
.node_tree
528 image_node
= node_tree
.nodes
.new("CompositorNodeImage")
529 image_node
.name
= name
+ "_image"
530 image_node
.image
= img_spec
.image
531 image_node
.location
= Vector((0, 0))
532 image_node
.frame_start
= img_spec
.frame_start
533 image_node
.frame_offset
= img_spec
.frame_offset
534 image_node
.frame_duration
= img_spec
.frame_duration
536 scale_node
= node_tree
.nodes
.new("CompositorNodeScale")
537 scale_node
.name
= name
+ "_scale"
538 scale_node
.space
= 'RENDER_SIZE'
539 scale_node
.location
= image_node
.location
+ \
540 Vector((image_node
.width
+ 20, 0))
541 scale_node
.show_options
= False
543 cornerpin_node
= node_tree
.nodes
.new("CompositorNodeCornerPin")
544 cornerpin_node
.name
= name
+ "_cornerpin"
545 cornerpin_node
.location
= scale_node
.location
+ \
546 Vector((0, -scale_node
.height
))
548 node_tree
.links
.new(scale_node
.inputs
[0], image_node
.outputs
[0])
549 node_tree
.links
.new(cornerpin_node
.inputs
[0], scale_node
.outputs
[0])
551 # Put all the nodes in a frame for organization
552 frame_node
= group_in_frame(
554 (image_node
, scale_node
, cornerpin_node
)
557 # Position frame at bottom / left
558 position_frame_bottom_left(node_tree
, frame_node
)
561 for corner
in cornerpin_node
.inputs
[1:]:
562 id = corner
.identifier
563 x
= -1 if 'Left' in id else 1
564 y
= -1 if 'Lower' in id else 1
565 drivers
= corner
.driver_add('default_value')
566 for i
, axis_fcurve
in enumerate(drivers
):
567 driver
= axis_fcurve
.driver
568 # Always use the current camera
569 add_driver_prop(driver
, 'camera', 'SCENE', scene
, 'camera')
570 # Track camera location to ensure Deps Graph triggers (not used in the call)
571 add_driver_prop(driver
, 'cam_loc_x', 'OBJECT', scene
.camera
, 'location[0]')
572 # Don't break if the name changes
573 add_driver_prop(driver
, 'name', 'OBJECT', plane
, 'name')
574 driver
.expression
= "import_image__find_plane_corner(name or %s, %d, %d, %d, camera=camera)" % (
578 driver
.type = 'SCRIPTED'
579 driver
.is_valid
= True
580 axis_fcurve
.is_valid
= True
581 driver
.expression
= "%s" % driver
.expression
583 context
.view_layer
.update()
586 # -----------------------------------------------------------------------------
589 class IMPORT_IMAGE_OT_to_plane(Operator
, AddObjectHelper
):
590 """Create mesh plane(s) from image files with the appropriate aspect ratio"""
592 bl_idname
= "import_image.to_plane"
593 bl_label
= "Import Images as Planes"
594 bl_options
= {'REGISTER', 'PRESET', 'UNDO'}
596 # ----------------------
597 # File dialog properties
598 files
: CollectionProperty(type=bpy
.types
.OperatorFileListElement
, options
={'HIDDEN', 'SKIP_SAVE'})
600 directory
: StringProperty(maxlen
=1024, subtype
='FILE_PATH', options
={'HIDDEN', 'SKIP_SAVE'})
602 filter_image
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
603 filter_movie
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
604 filter_folder
: BoolProperty(default
=True, options
={'HIDDEN', 'SKIP_SAVE'})
606 # ----------------------
607 # Properties - Importing
608 force_reload
: BoolProperty(
609 name
="Force Reload", default
=False,
610 description
="Force reloading of the image if already opened elsewhere in Blender"
613 image_sequence
: BoolProperty(
614 name
="Animate Image Sequences", default
=False,
615 description
="Import sequentially numbered images as an animated "
616 "image sequence instead of separate planes"
619 # -------------------------------------
620 # Properties - Position and Orientation
621 axis_id_to_vector
= {
622 'X+': Vector(( 1, 0, 0)),
623 'Y+': Vector(( 0, 1, 0)),
624 'Z+': Vector(( 0, 0, 1)),
625 'X-': Vector((-1, 0, 0)),
626 'Y-': Vector(( 0, -1, 0)),
627 'Z-': Vector(( 0, 0, -1)),
630 offset
: BoolProperty(name
="Offset Planes", default
=True, description
="Offset Planes From Each Other")
633 ('X+', "X+", "Side by Side to the Left"),
634 ('Y+', "Y+", "Side by Side, Downward"),
635 ('Z+', "Z+", "Stacked Above"),
636 ('X-', "X-", "Side by Side to the Right"),
637 ('Y-', "Y-", "Side by Side, Upward"),
638 ('Z-', "Z-", "Stacked Below"),
640 offset_axis
: EnumProperty(
641 name
="Orientation", default
='X+', items
=OFFSET_MODES
,
642 description
="How planes are oriented relative to each others' local axis"
645 offset_amount
: FloatProperty(
646 name
="Offset", soft_min
=0, default
=0.1, description
="Space between planes",
647 subtype
='DISTANCE', unit
='LENGTH'
651 ('X+', "X+", "Facing Positive X"),
652 ('Y+', "Y+", "Facing Positive Y"),
653 ('Z+', "Z+ (Up)", "Facing Positive Z"),
654 ('X-', "X-", "Facing Negative X"),
655 ('Y-', "Y-", "Facing Negative Y"),
656 ('Z-', "Z- (Down)", "Facing Negative Z"),
657 ('CAM', "Face Camera", "Facing Camera"),
658 ('CAM_AX', "Main Axis", "Facing the Camera's dominant axis"),
660 align_axis
: EnumProperty(
661 name
="Align", default
='CAM_AX', items
=AXIS_MODES
,
662 description
="How to align the planes"
664 # prev_align_axis is used only by update_size_model
665 prev_align_axis
: EnumProperty(
666 items
=AXIS_MODES
+ (('NONE', '', ''),), default
='NONE', options
={'HIDDEN', 'SKIP_SAVE'})
667 align_track
: BoolProperty(
668 name
="Track Camera", default
=False, description
="Always face the camera"
673 def update_size_mode(self
, context
):
674 """If sizing relative to the camera, always face the camera"""
675 if self
.size_mode
== 'CAMERA':
676 self
.prev_align_axis
= self
.align_axis
677 self
.align_axis
= 'CAM'
679 # if a different alignment was set revert to that when
680 # size mode is changed
681 if self
.prev_align_axis
!= 'NONE':
682 self
.align_axis
= self
.prev_align_axis
683 self
._prev
_align
_axis
= 'NONE'
686 ('ABSOLUTE', "Absolute", "Use absolute size"),
687 ('CAMERA', "Camera Relative", "Scale to the camera frame"),
688 ('DPI', "Dpi", "Use definition of the image as dots per inch"),
689 ('DPBU', "Dots/BU", "Use definition of the image as dots per Blender Unit"),
691 size_mode
: EnumProperty(
692 name
="Size Mode", default
='ABSOLUTE', items
=SIZE_MODES
,
693 update
=update_size_mode
,
694 description
="How the size of the plane is computed")
697 ('FILL', "Fill", "Fill camera frame, spilling outside the frame"),
698 ('FIT', "Fit", "Fit entire image within the camera frame"),
700 fill_mode
: EnumProperty(name
="Scale", default
='FILL', items
=FILL_MODES
,
701 description
="How large in the camera frame is the plane")
703 height
: FloatProperty(name
="Height", description
="Height of the created plane",
704 default
=1.0, min=0.001, soft_min
=0.001, subtype
='DISTANCE', unit
='LENGTH')
706 factor
: FloatProperty(name
="Definition", min=1.0, default
=600.0,
707 description
="Number of pixels per inch or Blender Unit")
709 # ------------------------------
710 # Properties - Material / Shader
712 ('PRINCIPLED',"Principled","Principled Shader"),
713 ('SHADELESS', "Shadeless", "Only visible to camera and reflections"),
714 ('EMISSION', "Emit", "Emission Shader"),
716 shader
: EnumProperty(name
="Shader", items
=SHADERS
, default
='PRINCIPLED', description
="Node shader to use")
718 emit_strength
: FloatProperty(
719 name
="Strength", min=0.0, default
=1.0, soft_max
=10.0,
720 step
=100, description
="Brightness of Emission Texture")
722 use_transparency
: BoolProperty(
723 name
="Use Alpha", default
=True,
724 description
="Use alpha channel for transparency")
727 ('BLEND',"Blend","Render polygon transparent, depending on alpha channel of the texture"),
728 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
729 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
730 ('OPAQUE', "Opaque","Render surface without transparency"),
732 blend_method
: EnumProperty(name
="Blend Mode", items
=BLEND_METHODS
, default
='BLEND', description
="Blend Mode for Transparent Faces")
735 ('CLIP', "Clip","Use the alpha threshold to clip the visibility (binary visibility)"),
736 ('HASHED', "Hashed","Use noise to dither the binary visibility (works well with multi-samples)"),
737 ('OPAQUE',"Opaque","Material will cast shadows without transparency"),
738 ('NONE',"None","Material will cast no shadow"),
740 shadow_method
: EnumProperty(name
="Shadow Mode", items
=SHADOW_METHODS
, default
='CLIP', description
="Shadow mapping method")
742 use_backface_culling
: BoolProperty(
743 name
="Backface Culling", default
=False,
744 description
="Use back face culling to hide the back side of faces")
746 show_transparent_back
: BoolProperty(
747 name
="Show Backface", default
=True,
748 description
="Render multiple transparent layers (may introduce transparency sorting problems)")
750 overwrite_material
: BoolProperty(
751 name
="Overwrite Material", default
=True,
752 description
="Overwrite existing Material (based on material name)")
754 compositing_nodes
: BoolProperty(
755 name
="Setup Corner Pin", default
=False,
756 description
="Build Compositor Nodes to reference this image "
757 "without re-rendering")
761 INTERPOLATION_MODES
= (
762 ('Linear', "Linear", "Linear interpolation"),
763 ('Closest', "Closest", "No interpolation (sample closest texel)"),
764 ('Cubic', "Cubic", "Cubic interpolation"),
765 ('Smart', "Smart", "Bicubic when magnifying, else bilinear (OSL only)"),
767 interpolation
: EnumProperty(name
="Interpolation", items
=INTERPOLATION_MODES
, default
='Linear', description
="Texture interpolation")
770 ('CLIP', "Clip", "Clip to image size and set exterior pixels as transparent"),
771 ('EXTEND', "Extend", "Extend by repeating edge pixels of the image"),
772 ('REPEAT', "Repeat", "Cause the image to repeat horizontally and vertically"),
774 extension
: EnumProperty(name
="Extension", items
=EXTENSION_MODES
, default
='CLIP', description
="How the image is extrapolated past its original bounds")
776 t
= bpy
.types
.Image
.bl_rna
.properties
["alpha_mode"]
777 alpha_mode_items
= tuple((e
.identifier
, e
.name
, e
.description
) for e
in t
.enum_items
)
778 alpha_mode
: EnumProperty(
779 name
=t
.name
, items
=alpha_mode_items
, default
=t
.default
,
780 description
=t
.description
)
782 t
= bpy
.types
.ImageUser
.bl_rna
.properties
["use_auto_refresh"]
783 use_auto_refresh
: BoolProperty(name
=t
.name
, default
=True, description
=t
.description
)
785 relative
: BoolProperty(name
="Relative Paths", default
=True, description
="Use relative file paths")
789 def draw_import_config(self
, context
):
790 # --- Import Options --- #
794 box
.label(text
="Import Options:", icon
='IMPORT')
796 row
.active
= bpy
.data
.is_saved
797 row
.prop(self
, "relative")
799 box
.prop(self
, "force_reload")
800 box
.prop(self
, "image_sequence")
802 def draw_material_config(self
, context
):
803 # --- Material / Rendering Properties --- #
807 box
.label(text
="Compositing Nodes:", icon
='RENDERLAYERS')
808 box
.prop(self
, "compositing_nodes")
811 box
.label(text
="Material Settings:", icon
='MATERIAL')
813 box
.label(text
="Material Type")
815 row
.prop(self
, 'shader', expand
=True)
816 if self
.shader
== 'EMISSION':
817 box
.prop(self
, "emit_strength")
819 box
.label(text
="Blend Mode")
821 row
.prop(self
, 'blend_method', expand
=True)
822 if self
.use_transparency
and self
.alpha_mode
!= "NONE" and self
.blend_method
== "OPAQUE":
823 box
.label(text
="'Opaque' does not support alpha", icon
="ERROR")
824 if self
.blend_method
== 'BLEND':
826 row
.prop(self
, "show_transparent_back")
828 box
.label(text
="Shadow Mode")
830 row
.prop(self
, 'shadow_method', expand
=True)
833 row
.prop(self
, "use_backface_culling")
835 engine
= context
.scene
.render
.engine
836 if engine
not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
837 box
.label(text
=tip_("%s is not supported") % engine
, icon
='ERROR')
839 box
.prop(self
, "overwrite_material")
842 box
.label(text
="Texture Settings:", icon
='TEXTURE')
843 box
.label(text
="Interpolation")
845 row
.prop(self
, 'interpolation', expand
=True)
846 box
.label(text
="Extension")
848 row
.prop(self
, 'extension', expand
=True)
850 row
.prop(self
, "use_transparency")
851 if self
.use_transparency
:
853 sub
.prop(self
, "alpha_mode", text
="")
855 row
.prop(self
, "use_auto_refresh")
857 def draw_spatial_config(self
, context
):
858 # --- Spatial Properties: Position, Size and Orientation --- #
862 box
.label(text
="Position:", icon
='SNAP_GRID')
863 box
.prop(self
, "offset")
866 row
.prop(self
, "offset_axis", expand
=True)
868 row
.prop(self
, "offset_amount")
869 col
.enabled
= self
.offset
871 box
.label(text
="Plane dimensions:", icon
='ARROW_LEFTRIGHT')
873 row
.prop(self
, "size_mode", expand
=True)
874 if self
.size_mode
== 'ABSOLUTE':
875 box
.prop(self
, "height")
876 elif self
.size_mode
== 'CAMERA':
878 row
.prop(self
, "fill_mode", expand
=True)
880 box
.prop(self
, "factor")
882 box
.label(text
="Orientation:")
884 row
.enabled
= 'CAM' not in self
.size_mode
885 row
.prop(self
, "align_axis")
887 row
.enabled
= 'CAM' in self
.align_axis
888 row
.alignment
= 'RIGHT'
889 row
.prop(self
, "align_track")
891 def draw(self
, context
):
893 # Draw configuration sections
894 self
.draw_import_config(context
)
895 self
.draw_material_config(context
)
896 self
.draw_spatial_config(context
)
898 # -------------------------------------------------------------------------
900 def invoke(self
, context
, event
):
901 engine
= context
.scene
.render
.engine
902 if engine
not in {'CYCLES', 'BLENDER_EEVEE'}:
903 if engine
!= 'BLENDER_WORKBENCH':
904 self
.report({'ERROR'}, tip_("Cannot generate materials for unknown %s render engine") % engine
)
907 self
.report({'WARNING'},
908 tip_("Generating Cycles/EEVEE compatible material, but won't be visible with %s engine") % engine
)
911 context
.window_manager
.fileselect_add(self
)
912 return {'RUNNING_MODAL'}
914 def execute(self
, context
):
915 if not bpy
.data
.is_saved
:
916 self
.relative
= False
918 # this won't work in edit mode
919 editmode
= context
.preferences
.edit
.use_enter_edit_mode
920 context
.preferences
.edit
.use_enter_edit_mode
= False
921 if context
.active_object
and context
.active_object
.mode
!= 'OBJECT':
922 bpy
.ops
.object.mode_set(mode
='OBJECT')
924 self
.import_images(context
)
926 context
.preferences
.edit
.use_enter_edit_mode
= editmode
930 def import_images(self
, context
):
932 # load images / sequences
933 images
= tuple(load_images(
934 (fn
.name
for fn
in self
.files
),
936 force_reload
=self
.force_reload
,
937 find_sequences
=self
.image_sequence
940 # Create individual planes
941 planes
= [self
.single_image_spec_to_plane(context
, img_spec
) for img_spec
in images
]
943 context
.view_layer
.update()
945 # Align planes relative to each other
947 offset_axis
= self
.axis_id_to_vector
[self
.offset_axis
]
948 offset_planes(planes
, self
.offset_amount
, offset_axis
)
950 if self
.size_mode
== 'CAMERA' and offset_axis
.z
:
952 x
, y
= compute_camera_size(
953 context
, plane
.location
,
954 self
.fill_mode
, plane
.dimensions
.x
/ plane
.dimensions
.y
)
955 plane
.dimensions
= x
, y
, 0.0
957 # setup new selection
959 plane
.select_set(True)
962 self
.report({'INFO'}, "Added {} Image Plane(s)".format(len(planes
)))
964 # operate on a single image
965 def single_image_spec_to_plane(self
, context
, img_spec
):
968 self
.apply_image_options(img_spec
.image
)
971 engine
= context
.scene
.render
.engine
972 if engine
in {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}:
973 material
= self
.create_cycles_material(context
, img_spec
)
975 # Create and position plane object
976 plane
= self
.create_image_plane(context
, material
.name
, img_spec
)
979 plane
.data
.materials
.append(material
)
981 # If applicable, setup Corner Pin node
982 if self
.compositing_nodes
:
983 setup_compositing(context
, plane
, img_spec
)
987 def apply_image_options(self
, image
):
988 if self
.use_transparency
== False:
989 image
.alpha_mode
= 'NONE'
991 image
.alpha_mode
= self
.alpha_mode
994 try: # can't always find the relative path (between drive letters on windows)
995 image
.filepath
= bpy
.path
.relpath(image
.filepath
)
999 def apply_texture_options(self
, texture
, img_spec
):
1000 # Shared by both Cycles and Blender Internal
1001 image_user
= texture
.image_user
1002 image_user
.use_auto_refresh
= self
.use_auto_refresh
1003 image_user
.frame_start
= img_spec
.frame_start
1004 image_user
.frame_offset
= img_spec
.frame_offset
1005 image_user
.frame_duration
= img_spec
.frame_duration
1007 # Image sequences need auto refresh to display reliably
1008 if img_spec
.image
.source
== 'SEQUENCE':
1009 image_user
.use_auto_refresh
= True
1011 def apply_material_options(self
, material
, slot
):
1012 shader
= self
.shader
1014 if self
.use_transparency
:
1015 material
.alpha
= 0.0
1016 material
.specular_alpha
= 0.0
1017 slot
.use_map_alpha
= True
1019 material
.alpha
= 1.0
1020 material
.specular_alpha
= 1.0
1021 slot
.use_map_alpha
= False
1023 material
.specular_intensity
= 0
1024 material
.diffuse_intensity
= 1.0
1025 material
.use_transparency
= self
.use_transparency
1026 material
.transparency_method
= 'Z_TRANSPARENCY'
1027 material
.use_shadeless
= (shader
== 'SHADELESS')
1028 material
.use_transparent_shadows
= (shader
== 'DIFFUSE')
1029 material
.emit
= self
.emit_strength
if shader
== 'EMISSION' else 0.0
1031 # -------------------------------------------------------------------------
1033 def create_cycles_texnode(self
, context
, node_tree
, img_spec
):
1034 tex_image
= node_tree
.nodes
.new('ShaderNodeTexImage')
1035 tex_image
.image
= img_spec
.image
1036 tex_image
.show_texture
= True
1037 tex_image
.interpolation
= self
.interpolation
1038 tex_image
.extension
= self
.extension
1039 self
.apply_texture_options(tex_image
, img_spec
)
1042 def create_cycles_material(self
, context
, img_spec
):
1043 image
= img_spec
.image
1044 name_compat
= bpy
.path
.display_name_from_filepath(image
.filepath
)
1046 if self
.overwrite_material
:
1047 for mat
in bpy
.data
.materials
:
1048 if mat
.name
== name_compat
:
1051 material
= bpy
.data
.materials
.new(name
=name_compat
)
1053 material
.use_nodes
= True
1055 material
.blend_method
= self
.blend_method
1056 material
.shadow_method
= self
.shadow_method
1058 material
.use_backface_culling
= self
.use_backface_culling
1059 material
.show_transparent_back
= self
.show_transparent_back
1061 node_tree
= material
.node_tree
1062 out_node
= clean_node_tree(node_tree
)
1064 tex_image
= self
.create_cycles_texnode(context
, node_tree
, img_spec
)
1066 if self
.shader
== 'PRINCIPLED':
1067 core_shader
= node_tree
.nodes
.new('ShaderNodeBsdfPrincipled')
1068 elif self
.shader
== 'SHADELESS':
1069 core_shader
= get_shadeless_node(node_tree
)
1070 elif self
.shader
== 'EMISSION':
1071 core_shader
= node_tree
.nodes
.new('ShaderNodeBsdfPrincipled')
1072 core_shader
.inputs
['Emission Strength'].default_value
= self
.emit_strength
1073 core_shader
.inputs
['Base Color'].default_value
= (0.0, 0.0, 0.0, 1.0)
1074 core_shader
.inputs
['Specular'].default_value
= 0.0
1076 # Connect color from texture
1077 if self
.shader
in {'PRINCIPLED', 'SHADELESS'}:
1078 node_tree
.links
.new(core_shader
.inputs
[0], tex_image
.outputs
['Color'])
1079 elif self
.shader
== 'EMISSION':
1080 node_tree
.links
.new(core_shader
.inputs
['Emission'], tex_image
.outputs
['Color'])
1082 if self
.use_transparency
:
1083 if self
.shader
in {'PRINCIPLED', 'EMISSION'}:
1084 node_tree
.links
.new(core_shader
.inputs
['Alpha'], tex_image
.outputs
['Alpha'])
1086 bsdf_transparent
= node_tree
.nodes
.new('ShaderNodeBsdfTransparent')
1088 mix_shader
= node_tree
.nodes
.new('ShaderNodeMixShader')
1089 node_tree
.links
.new(mix_shader
.inputs
['Fac'], tex_image
.outputs
['Alpha'])
1090 node_tree
.links
.new(mix_shader
.inputs
[1], bsdf_transparent
.outputs
['BSDF'])
1091 node_tree
.links
.new(mix_shader
.inputs
[2], core_shader
.outputs
[0])
1092 core_shader
= mix_shader
1094 node_tree
.links
.new(out_node
.inputs
['Surface'], core_shader
.outputs
[0])
1096 auto_align_nodes(node_tree
)
1099 # -------------------------------------------------------------------------
1101 def create_image_plane(self
, context
, name
, img_spec
):
1103 width
, height
= self
.compute_plane_size(context
, img_spec
)
1106 bpy
.ops
.mesh
.primitive_plane_add('INVOKE_REGION_WIN')
1107 plane
= context
.active_object
1108 # Why does mesh.primitive_plane_add leave the object in edit mode???
1109 if plane
.mode
!= 'OBJECT':
1110 bpy
.ops
.object.mode_set(mode
='OBJECT')
1111 plane
.dimensions
= width
, height
, 0.0
1112 plane
.data
.name
= plane
.name
= name
1113 bpy
.ops
.object.transform_apply(location
=False, rotation
=False, scale
=True)
1115 # If sizing for camera, also insert into the camera's field of view
1116 if self
.size_mode
== 'CAMERA':
1117 offset_axis
= self
.axis_id_to_vector
[self
.offset_axis
]
1118 translate_axis
= [0 if offset_axis
[i
] else 1 for i
in (0, 1)]
1119 center_in_camera(context
.scene
, context
.scene
.camera
, plane
, translate_axis
)
1121 self
.align_plane(context
, plane
)
1125 def compute_plane_size(self
, context
, img_spec
):
1126 """Given the image size in pixels and location, determine size of plane"""
1127 px
, py
= img_spec
.size
1130 if px
== 0 or py
== 0:
1133 if self
.size_mode
== 'ABSOLUTE':
1137 elif self
.size_mode
== 'CAMERA':
1138 x
, y
= compute_camera_size(
1139 context
, context
.scene
.cursor
.location
,
1140 self
.fill_mode
, px
/ py
1143 elif self
.size_mode
== 'DPI':
1144 fact
= 1 / self
.factor
/ context
.scene
.unit_settings
.scale_length
* 0.0254
1148 else: # elif self.size_mode == 'DPBU'
1149 fact
= 1 / self
.factor
1155 def align_plane(self
, context
, plane
):
1156 """Pick an axis and align the plane to it"""
1157 if 'CAM' in self
.align_axis
:
1159 camera
= context
.scene
.camera
1161 # Find the axis that best corresponds to the camera's view direction
1162 axis
= camera
.matrix_world
@ \
1163 Vector((0, 0, 1)) - camera
.matrix_world
.col
[3].xyz
1164 # pick the axis with the greatest magnitude
1165 mag
= max(map(abs, axis
))
1166 # And use that axis & direction
1168 n
/ mag
if abs(n
) == mag
else 0.0
1172 # No camera? Just face Z axis
1173 axis
= Vector((0, 0, 1))
1174 self
.align_axis
= 'Z+'
1177 axis
= self
.axis_id_to_vector
[self
.align_axis
]
1179 # rotate accordingly for x/y axiis
1181 plane
.rotation_euler
.x
= pi
/ 2
1184 plane
.rotation_euler
.z
= pi
1186 plane
.rotation_euler
.z
= 0
1188 plane
.rotation_euler
.z
= pi
/ 2
1190 plane
.rotation_euler
.z
= -pi
/ 2
1192 # or flip 180 degrees for negative z
1194 plane
.rotation_euler
.y
= pi
1196 if self
.align_axis
== 'CAM':
1197 constraint
= plane
.constraints
.new('COPY_ROTATION')
1198 constraint
.target
= camera
1199 constraint
.use_x
= constraint
.use_y
= constraint
.use_z
= True
1200 if not self
.align_track
:
1201 bpy
.ops
.object.visual_transform_apply()
1202 plane
.constraints
.clear()
1204 if self
.align_axis
== 'CAM_AX' and self
.align_track
:
1205 constraint
= plane
.constraints
.new('LOCKED_TRACK')
1206 constraint
.target
= camera
1207 constraint
.track_axis
= 'TRACK_Z'
1208 constraint
.lock_axis
= 'LOCK_Y'
1211 # -----------------------------------------------------------------------------
1214 def import_images_button(self
, context
):
1215 self
.layout
.operator(IMPORT_IMAGE_OT_to_plane
.bl_idname
, text
="Images as Planes", icon
='TEXTURE')
1219 IMPORT_IMAGE_OT_to_plane
,
1225 bpy
.utils
.register_class(cls
)
1227 bpy
.types
.TOPBAR_MT_file_import
.append(import_images_button
)
1228 bpy
.types
.VIEW3D_MT_image_add
.append(import_images_button
)
1230 bpy
.app
.handlers
.load_post
.append(register_driver
)
1235 bpy
.types
.TOPBAR_MT_file_import
.remove(import_images_button
)
1236 bpy
.types
.VIEW3D_MT_image_add
.remove(import_images_button
)
1238 # This will only exist if drivers are active
1239 if check_drivers
in bpy
.app
.handlers
.depsgraph_update_post
:
1240 bpy
.app
.handlers
.depsgraph_update_post
.remove(check_drivers
)
1242 bpy
.app
.handlers
.load_post
.remove(register_driver
)
1243 del bpy
.app
.driver_namespace
['import_image__find_plane_corner']
1246 bpy
.utils
.unregister_class(cls
)
1249 if __name__
== "__main__":
1250 # Run simple doc tests