Files
blender-portable-repo/scripts/addons/lattice_magic/camera_lattice.py
T
2026-03-17 14:58:51 -06:00

641 lines
21 KiB
Python

# SPDX-FileCopyrightText: 2015 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Inspired by https://animplay.wordpress.com/2015/11/18/smear-frame-script-maya/.
# This addon allows the user to specify a camera and a collection,
# and create a 2D lattice that fills the camera's view,
# to deform the mesh objects in that collection.
import bpy
import math
from bpy.app.handlers import persistent
from mathutils import Vector
from bpy.props import (
BoolProperty,
PointerProperty,
CollectionProperty,
IntProperty,
EnumProperty,
FloatProperty,
)
from mathutils.geometry import intersect_point_line
from .utils import bounding_box_center_of_objects
from .prefs import get_addon_prefs
class CAMLAT_UL_lattice_slots(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
lattice_slots = context.scene.lattice_slots
active_slot = lattice_slots[context.scene.active_lattice_index]
current_slot = item
if self.layout_type in {'DEFAULT', 'COMPACT'}:
if current_slot.collection:
row = layout.row()
icon = 'OUTLINER_COLLECTION' if current_slot.enabled else 'COLLECTION_COLOR_07'
row.prop(current_slot.collection, 'name', text="", emboss=False, icon=icon)
row.enabled = current_slot.enabled
layout.prop(current_slot, 'strength', text="", slider=True, emboss=False)
icon = 'CHECKBOX_HLT' if current_slot.enabled else 'CHECKBOX_DEHLT'
layout.prop(current_slot, 'enabled', text="", icon=icon, emboss=False)
else:
layout.label(text="", translate=False, icon='COLLECTION_NEW')
elif self.layout_type in {'GRID'}:
layout.alignment = 'CENTER'
layout.label(text="", icon_value=icon)
class LatticeSlot(bpy.types.PropertyGroup):
enabled: BoolProperty(
name="Enabled", description="Whether the Lattice has an effect or not", default=True
)
strength: FloatProperty(
name="Strength", description="Strength of the lattice effect", min=0, max=1, default=1
)
lattice: PointerProperty(
name="Lattice",
type=bpy.types.Object,
description="Lattice object generated by this LatticeSlot. This cannot be specified manually, use the Generate or Delete operator below",
)
def is_camera(self, obj):
return obj.type == 'CAMERA'
camera: PointerProperty(
name="Camera",
type=bpy.types.Object,
description="Camera used by this LatticeSlot",
poll=is_camera,
)
collection: PointerProperty(
name="Collection",
type=bpy.types.Collection,
description="Collection affected by this LatticeSlot",
)
resolution: IntProperty(
name="Resolution",
description="Resolution of the lattice grid",
min=5,
max=64,
default=10,
options=set(),
)
class OBJECT_OT_camlattice_add(bpy.types.Operator):
"""Add a Camera Lattice Slot"""
bl_idname = "lattice.add_slot"
bl_label = "Add Lattice Slot"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
scene = context.scene
lattice_slots = scene.lattice_slots
active_index = scene.active_lattice_index
to_index = active_index + 1
if len(lattice_slots) == 0:
to_index = 0
scene.lattice_slots.add()
scene.lattice_slots.move(len(scene.lattice_slots) - 1, to_index)
scene.active_lattice_index = to_index
return {'FINISHED'}
class OBJECT_OT_camlattice_remove(bpy.types.Operator):
"""Remove Lattice Slot along with its Lattice object, animation and modifiers"""
bl_idname = "lattice.remove_slot"
bl_label = "Remove Lattice Slot"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
index: IntProperty()
@classmethod
def poll(cls, context):
scene = context.scene
if len(scene.lattice_slots) > 0:
return True
cls.poll_message_set("No slots to remove.")
return False
def execute(self, context):
scene = context.scene
lattice_slots = scene.lattice_slots
active_index = scene.active_lattice_index
# This behaviour is inconsistent with other UILists in Blender, but I am right and they are wrong!
active_slot = lattice_slots[active_index]
if active_slot.lattice:
bpy.ops.lattice.delete_lattice_from_slot()
to_index = active_index
if to_index > len(lattice_slots) - 2:
to_index = len(lattice_slots) - 2
scene.lattice_slots.remove(self.index)
scene.active_lattice_index = to_index
return {'FINISHED'}
class OBJECT_OT_camlattice_move(bpy.types.Operator):
"""Move Lattice Slot"""
bl_idname = "lattice.move_slot"
bl_label = "Move Lattice Slot"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
direction: EnumProperty(
name="Direction",
items=[
('UP', 'UP', 'UP'),
('DOWN', 'DOWN', 'DOWN'),
],
default='UP',
)
@classmethod
def poll(cls, context):
scene = context.scene
if len(scene.lattice_slots) > 1:
return True
cls.poll_message_set("No slots to re-order.")
return False
def execute(self, context):
scene = context.scene
lattice_slots = scene.lattice_slots
active_index = scene.active_lattice_index
to_index = active_index + (1 if self.direction == 'DOWN' else -1)
if to_index > len(lattice_slots) - 1:
to_index = 0
if to_index < 0:
to_index = len(lattice_slots) - 1
scene.lattice_slots.move(active_index, to_index)
scene.active_lattice_index = to_index
return {'FINISHED'}
class OBJECT_OT_camlattice_generate(bpy.types.Operator):
"""Generate a lattice to smear the selected collection from the selected camera"""
bl_idname = "lattice.generate_lattice_for_slot"
bl_label = "Generate Lattice"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context):
scene = context.scene
active_slot = scene.lattice_slots[scene.active_lattice_index]
if not active_slot.collection:
cls.poll_message_set("A collection must be selected above.")
return False
if not active_slot.camera:
cls.poll_message_set("A camera must be selected above.")
return False
if active_slot.lattice:
cls.poll_message_set("This slot already has a lattice generated for it. You can delete it above if you wish to re-generate it.")
return False
return True
def execute(self, context):
scene = context.scene
active_slot = scene.lattice_slots[scene.active_lattice_index]
collection = active_slot.collection
camera = active_slot.camera
resolution = active_slot.resolution
if context.active_object and context.active_object.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# Create a lattice object.
lattice_name = "Lattice_" + collection.name
lattice = bpy.data.lattices.new(lattice_name)
lattice_ob = bpy.data.objects.new(lattice_name, lattice)
scene.collection.objects.link(lattice_ob)
active_slot.lattice = lattice_ob
bpy.ops.object.select_all(action='DESELECT')
context.view_layer.objects.active = lattice_ob
lattice_ob.select_set(True)
# Align to camera (not really needed).
lattice_ob.rotation_euler = camera.matrix_world.to_euler()
# Parent to camera.
lattice_ob.parent = camera
lattice_ob.matrix_parent_inverse = camera.matrix_world.inverted()
# Constrain to camera.
constraint = lattice_ob.constraints.new('DAMPED_TRACK')
constraint.target = camera
constraint.track_axis = 'TRACK_Z'
### Placing the Lattice in the center of the camera's view, at the bounding box center of the collection's objects.
# Find the bounding box center of the collection of objects
all_meshes = [o for o in collection.all_objects if o.type == 'MESH']
center = bounding_box_center_of_objects(all_meshes)
# Define a line from the camera towards the camera's view direction
cam_vec = Vector((0, 0, -1)) # Default aim vector of a camera (they point straight down)
# Rotate the default vector by the camera's rotation
cam_vec.rotate(camera.matrix_world.to_euler())
cam_world_pos = camera.matrix_world.to_translation()
cam_target_pos = cam_world_pos + cam_vec
# Find the nearest point on this line to the bounding box center
intersect = intersect_point_line(center, cam_world_pos, cam_target_pos)[0]
# This is where the Lattice is placed!
lattice_ob.location = intersect
# Scale the lattice so that it fills up the camera's view
# based on the distance of this point from the camera and the scene's aspect ratio.
# https://fullpipeumbrella.com/en/blender-python-script-how-to-position/
distance = (intersect - cam_world_pos).length
fov = camera.data.angle
scale_x = distance * math.sin(fov / 2) / math.cos(fov / 2) * 2
aspect_ratio = (scene.render.resolution_x * scene.render.pixel_aspect_x) / (
scene.render.resolution_y * scene.render.pixel_aspect_y
)
scale_y = scale_x / aspect_ratio
lattice_ob.scale = [scale_x, scale_y, 1]
# Set lattice resolution
lattice.points_u = resolution
lattice.points_v = round(resolution / aspect_ratio)
lattice.points_w = 1
# Create two shape keys.
bpy.ops.lattice.smear_add_shape()
bpy.ops.lattice.smear_add_shape()
# Add Lattice modifiers
for ob in all_meshes:
# Skip those meshes which are already being deformed by another mesh in the same collection.
skip = False
for m in ob.modifiers:
if m.type == 'MESH_DEFORM' and m.object in all_meshes:
skip = True
break
if m.type == 'SURFACE_DEFORM' and m.target in all_meshes:
skip = True
break
if not ob.visible_get():
skip = True
if skip:
continue
mod = ob.modifiers.new(name=lattice_ob.name, type='LATTICE')
mod.object = lattice_ob
# Add drivers for easy disabling
index = len(scene.lattice_slots) - 1
driver = ob.driver_add(f'modifiers["{lattice_ob.name}"].strength').driver
driver.type = 'SUM'
var = driver.variables.new()
var.targets[0].id_type = 'SCENE'
var.targets[0].id = scene
var.targets[0].data_path = f'lattice_slots[{index}].strength'
driver = ob.driver_add(f'modifiers["{lattice_ob.name}"].show_viewport').driver
driver.type = 'SUM'
var = driver.variables.new()
var.targets[0].id_type = 'SCENE'
var.targets[0].id = scene
var.targets[0].data_path = f'lattice_slots[{index}].enabled'
driver = ob.driver_add(f'modifiers["{lattice_ob.name}"].show_render').driver
driver.type = 'SUM'
var = driver.variables.new()
var.targets[0].id_type = 'SCENE'
var.targets[0].id = scene
var.targets[0].data_path = f'lattice_slots[{index}].enabled'
return {'FINISHED'}
class OBJECT_OT_camlattice_delete(bpy.types.Operator):
"""Delete Lattice object, its animation and modifiers that target it in the selected collection's objects"""
bl_idname = "lattice.delete_lattice_from_slot"
bl_label = "Delete Lattice"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context):
scene = context.scene
active_slot = scene.lattice_slots[scene.active_lattice_index]
if not active_slot.lattice:
cls.poll_message_set("This slot has no lattice to delete.")
return False
return True
def execute(self, context):
scene = context.scene
active_slot = scene.lattice_slots[scene.active_lattice_index]
lattice_ob = active_slot.lattice
lattice = lattice_ob.data
# Delete modifiers and their drivers
collection = active_slot.collection
for ob in collection.all_objects:
if not ob.type == 'MESH':
continue
for m in ob.modifiers[:]:
if not (m.type == 'LATTICE' and m.object == lattice_ob):
continue
ob.driver_remove(f'modifiers["{m.name}"].strength')
ob.driver_remove(f'modifiers["{m.name}"].show_viewport')
ob.driver_remove(f'modifiers["{m.name}"].show_render')
ob.modifiers.remove(m)
# Delete animation datablocks
datablocks = [lattice, lattice_ob, lattice.shape_keys]
for datablock in datablocks:
if not datablock:
continue
if not datablock.animation_data:
continue
if not datablock.animation_data.action:
continue
bpy.data.actions.remove(datablock.animation_data.action)
# Delte Lattice datablock
bpy.data.objects.remove(lattice_ob)
# Delete Object datablock
bpy.data.lattices.remove(lattice)
return {'FINISHED'}
class OBJECT_OT_camlattice_shapekey_add(bpy.types.Operator):
"""Add a shape key to the active Lattice Slot's lattice, named after the current frame number"""
bl_idname = "lattice.smear_add_shape"
bl_label = "Add Smear Shape"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
scene = context.scene
active_slot = scene.lattice_slots[scene.active_lattice_index]
lattice_ob = active_slot.lattice
lattice = lattice_ob.data
name = "Basis"
if lattice.shape_keys:
name = "Frame " + str(scene.frame_current)
lattice_ob.shape_key_add(name=name, from_mix=False)
lattice_ob.active_shape_key_index = len(lattice.shape_keys.key_blocks) - 1
block = lattice.shape_keys.key_blocks[-1]
block.value = 1
return {'FINISHED'}
def shape_key_poll(context):
ob = context.object
if not ob or ob.type != 'LATTICE':
return False
if not ob.data.shape_keys or len(ob.data.shape_keys.key_blocks) < 2:
return False
return True
class OBJECT_OT_camlattice_zero_all(bpy.types.Operator):
"""Set all shape key values to 0"""
bl_idname = "lattice.shape_keys_zero_all"
bl_label = "Zero All Shape Keys"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context):
return shape_key_poll(context)
def execute(self, context):
scene = context.scene
active_slot = scene.lattice_slots[scene.active_lattice_index]
lattice_ob = active_slot.lattice
lattice = lattice_ob.data
for sk in lattice.shape_keys.key_blocks:
sk.value = 0
return {'FINISHED'}
class OBJECT_OT_camlattice_keyframe_all(bpy.types.Operator):
"""Insert a keyframe on the current frame for all shape key values"""
bl_idname = "lattice.shape_keys_keyframe_all"
bl_label = "Keyframe All Shape Keys"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context):
return shape_key_poll(context)
def execute(self, context):
scene = context.scene
active_slot = scene.lattice_slots[scene.active_lattice_index]
lattice_ob = active_slot.lattice
lattice = lattice_ob.data
for sk in lattice.shape_keys.key_blocks:
sk.keyframe_insert('value')
return {'FINISHED'}
class VIEW3D_PT_camlattice_panel(bpy.types.Panel):
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Lattice Magic'
bl_label = "Camera Lattice"
@classmethod
def poll(cls, context):
return True
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
scene = context.scene
active_index = scene.active_lattice_index
row = layout.row()
row.template_list(
'CAMLAT_UL_lattice_slots',
'',
scene,
'lattice_slots',
scene,
'active_lattice_index',
)
col = row.column()
col.operator(OBJECT_OT_camlattice_add.bl_idname, text="", icon='ADD')
remove_op = col.operator(OBJECT_OT_camlattice_remove.bl_idname, text="", icon='REMOVE')
remove_op.index = active_index
col.separator()
move_up_op = col.operator(OBJECT_OT_camlattice_move.bl_idname, text="", icon='TRIA_UP')
move_up_op.direction = 'UP'
move_down_op = col.operator(OBJECT_OT_camlattice_move.bl_idname, text="", icon='TRIA_DOWN')
move_down_op.direction = 'DOWN'
if len(scene.lattice_slots) == 0:
return
active_slot = scene.lattice_slots[scene.active_lattice_index]
col = layout.column()
if active_slot.lattice:
col.enabled = False
row = col.row()
if not active_slot.collection:
row.alert = True
row.prop(active_slot, 'collection')
row = col.row()
if not active_slot.camera:
row.alert = True
row.prop(active_slot, 'camera', icon='OUTLINER_OB_CAMERA')
col.prop(active_slot, 'resolution')
layout.separator()
if not active_slot.lattice:
layout.operator(OBJECT_OT_camlattice_generate.bl_idname, icon='OUTLINER_OB_LATTICE')
else:
layout.operator(OBJECT_OT_camlattice_delete.bl_idname, icon='TRASH')
row = layout.row()
row.enabled = False
row.prop(active_slot, 'lattice')
if not active_slot.lattice:
return
lattice_ob = active_slot.lattice
lattice = lattice_ob.data
col = layout.column()
row = layout.row(align=True)
row.operator(OBJECT_OT_camlattice_zero_all.bl_idname, text="", icon='RADIOBUT_OFF')
row.operator(OBJECT_OT_camlattice_keyframe_all.bl_idname, text="", icon='HANDLETYPE_FREE_VEC')
row.separator()
prefs = get_addon_prefs(context)
row.prop(prefs, 'update_active_shape_key', toggle=True, text="", icon='TIME')
row = layout.row()
# Display the lattice's Shape Keys in a less cluttered way than in the Properties editor.
row.template_list(
'MESH_UL_shape_keys',
'',
lattice.shape_keys,
'key_blocks',
lattice_ob,
'active_shape_key_index',
)
col = row.column()
col.operator(OBJECT_OT_camlattice_shapekey_add.bl_idname, text="", icon='ADD')
remove_op = col.operator('object.shape_key_remove', text="", icon='REMOVE')
col.separator()
col.menu("MESH_MT_shape_key_context_menu", icon='DOWNARROW_HLT', text="")
col.separator()
move_up_op = col.operator('object.shape_key_move', text="", icon='TRIA_UP')
move_up_op.type = 'UP'
move_down_op = col.operator('object.shape_key_move', text="", icon='TRIA_DOWN')
move_down_op.type = 'DOWN'
@persistent
def camera_lattice_frame_change(scene):
"""On frame change, set the active shape key of the active lattice object to the most recent frame
(Assuming the shape keys are named after the frame on which they are used)
"""
# I wonder why this function doesn't recieve a context... should it not be relied on from here? o.0
context = bpy.context
prefs = get_addon_prefs(context)
if not prefs.update_active_shape_key:
return
ob = context.object
if not shape_key_poll(context):
return
key_blocks = ob.data.shape_keys.key_blocks
current_frame = scene.frame_current
most_recent_number = 0
most_recent_index = 1
for i, kb in enumerate(key_blocks):
if not kb.name.startswith('Frame '):
continue
number_str = kb.name[5:].split(".")[0]
if number_str == "":
continue
number = int(number_str)
if number <= current_frame and number >= most_recent_number:
most_recent_number = number
most_recent_index = i
if most_recent_number == current_frame:
break
if ob.active_shape_key_index != most_recent_index:
ob.active_shape_key_index = most_recent_index
registry = [
LatticeSlot,
CAMLAT_UL_lattice_slots,
OBJECT_OT_camlattice_add,
OBJECT_OT_camlattice_remove,
OBJECT_OT_camlattice_move,
OBJECT_OT_camlattice_generate,
OBJECT_OT_camlattice_delete,
OBJECT_OT_camlattice_shapekey_add,
OBJECT_OT_camlattice_zero_all,
OBJECT_OT_camlattice_keyframe_all,
VIEW3D_PT_camlattice_panel,
]
def register():
bpy.types.Scene.lattice_slots = CollectionProperty(type=LatticeSlot)
bpy.types.Scene.active_lattice_index = IntProperty()
bpy.app.handlers.frame_change_post.append(camera_lattice_frame_change)
def unregister():
del bpy.types.Scene.lattice_slots
del bpy.types.Scene.active_lattice_index
bpy.app.handlers.frame_change_post.remove(camera_lattice_frame_change)