Add Lattice Magic
to Addons
#48
63
scripts-blender/addons/lattice_magic/README.md
Normal file
63
scripts-blender/addons/lattice_magic/README.md
Normal file
@ -0,0 +1,63 @@
|
||||
This addon adds some Lattice-based utilities to Blender.
|
||||
|
||||
Install like any other Blender addon: Download this repository as a .zip archive, then in Blender go to Edit->Preferences->Addons->Install Addon from File, and browse the zip you downloaded, and enable the checkbox for the addon.
|
||||
|
||||
After that, you can find the Lattice Magic panel in the 3D Viewport's Sidebar, which you can bring up by pressing the N key.
|
||||
|
||||
![](docs/lattice_magic.png)
|
||||
|
||||
# Tweak Lattice
|
||||
Tweak Lattice lets you create a lattice setup at the 3D cursor to make deformation adjustments to the selected objects.
|
||||
|
||||
![](docs/tweak_lattice.gif)
|
||||
|
||||
### Parenting
|
||||
This is meant to be possible to be used in conjunction with a character rig: Before pressing the "Create Tweak Lattice" button, simply select the desired parent rig object and bone in the UI.
|
||||
|
||||
### Deletion
|
||||
If you want to delete a lattice, don't just delete the empty object that was created for you. This would leave behind a big mess of broken modifiers and drivers which will cause tremendous error printing spam in your console/terminal. Instead, use the "Delete Tweak Lattice" button.
|
||||
|
||||
### Adding/Removing meshes
|
||||
When creating a lattice, it will affect all mesh objects which were selected at the moment of its creation.
|
||||
|
||||
If you want more meshes to be influenced by a lattice, you don't need to delete it and re-create it with a different selection. Just select the objects you want to add to or remove from the lattice's influence, then finally select the lattice control. There will now be an "Add Selected Objects" and "Remove Selected Objects" button.
|
||||
|
||||
### Going under the hood
|
||||
With the lattice control selected, you can see a "Helper Objects" section in the UI. This lists two objects which are taking care of things under the hood. If you want, you can enable them with the screen icon, which will let you mess with them. This should rarely be necessary though, and you should only do it at your own risk, since there's no way to get these back to their original states once modified.
|
||||
|
||||
|
||||
# Camera Lattice
|
||||
Camera Lattice lets you create a lattice in a camera's view frame and deform a character (or any collection) with the lattice.
|
||||
|
||||
![](docs/camera_lattice.gif)
|
||||
|
||||
### Creation
|
||||
Add an entry to the Camera Lattice list with the + icon. Each entry corresponds to deforming a single collection with a single lattice object from the perspective of a single camera.
|
||||
|
||||
You must select a collection and a camera, then hit Generate Lattice. Note that you cannot change the resolution after the fact, so find a resolution that you're happy with, as you will be locked into that.
|
||||
|
||||
### Parenting
|
||||
On creation, the lattice is parented to the camera. You can feel free to remove or change this parenting to your heart's desire, it shouldn't cause any issues. The lattice object also has a Damped Track constraint, the same applies there: You can remove it if you want.
|
||||
|
||||
Just remember, there's no reset button for these sort of things.
|
||||
|
||||
### Animation
|
||||
Feel free to animate the lattice in object mode as you wish, although unless the above mentioned Damped Track constraint is enabled, you will only be able to rotate it on one axis.
|
||||
|
||||
Animating the lattice's vertices is possible using shape keys. The addon provides some UI and helper operators for this, but at the end of the day it's up to you how you organize and keyframe these shape keys.
|
||||
The intended workflow is that a shape key should only be active for a single frame. To help with this, shape keys are named when they are added, according to the current frame. There are also some buttons above the list:
|
||||
- Zero All Shape Keys: Operator to set all shape key values to 0.0. This does not insert a keyframe!
|
||||
- Keyframe All Shape Keys: Operator to insert a keyframe for all shape keys on the current frame with their current value.
|
||||
- Update Active Shape Key: Toggle to automatically change the active shape key based on the current frame. Useful when switching into edit mode quickly on different frames.
|
||||
|
||||
Note that Blender is not capable of displaying the effect of multiple shape keys on a lattice at the same time, which is another reason to go with the intended workflow, since that will always only have one shape key active at a time.
|
||||
|
||||
|
||||
### Deletion
|
||||
Similar to Tweak Lattice, never ever delete a lattice setup by simply pressing the X or Del keys, as this will leave behind a huge mess. Instead, use the "Delete Lattice" button, or the "-" button in the top list.
|
||||
|
||||
|
||||
### TODO
|
||||
Some ideas that could be implemented for Camera Lattice:
|
||||
- Automatically inserting new shape key in the correct place in the list. Eg., when Frame 1 and Frame 10 already exist, creating a shape key on Frame 5 should insert it in between them.
|
||||
- Adding or removing objects to the influence of the lattice is not currently possible.
|
67
scripts-blender/addons/lattice_magic/__init__.py
Normal file
67
scripts-blender/addons/lattice_magic/__init__.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Copyright (C) 2020 Demeter Dzadik
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
bl_info = {
|
||||
"name": "Lattice Magic",
|
||||
"author": "Demeter Dzadik",
|
||||
"version": (1,0),
|
||||
"blender": (2, 90, 0),
|
||||
"location": "View3D > Sidebar > Lattice Magic",
|
||||
"description": "Various Lattice-based tools to smear or adjust geometry.",
|
||||
"category": "Rigging",
|
||||
"doc_url": "https://gitlab.com/blender/lattice_magic/-/wikis/home",
|
||||
"tracker_url": "https://gitlab.com/blender/lattice_magic/-/issues/new",
|
||||
}
|
||||
|
||||
from . import camera_lattice
|
||||
from . import tweak_lattice
|
||||
from . import operators
|
||||
from . import utils # Just for importlib.reload()
|
||||
import importlib
|
||||
|
||||
import bpy
|
||||
from bpy.types import AddonPreferences
|
||||
from bpy.props import BoolProperty
|
||||
|
||||
class LatticeMagicPreferences(AddonPreferences):
|
||||
bl_idname = __name__
|
||||
|
||||
update_active_shape_key: BoolProperty(
|
||||
name = 'Update Active Shape Key',
|
||||
description = "Update the active shape key on frame change based on the current frame and the shape key's name",
|
||||
default = False
|
||||
)
|
||||
|
||||
modules = [
|
||||
camera_lattice
|
||||
,tweak_lattice
|
||||
,operators
|
||||
,utils
|
||||
]
|
||||
|
||||
def register():
|
||||
from bpy.utils import register_class
|
||||
register_class(LatticeMagicPreferences)
|
||||
for m in modules:
|
||||
importlib.reload(m)
|
||||
if hasattr(m, 'register'):
|
||||
m.register()
|
||||
|
||||
def unregister():
|
||||
from bpy.utils import unregister_class
|
||||
unregister_class(LatticeMagicPreferences)
|
||||
for m in modules:
|
||||
if hasattr(m, 'unregister'):
|
||||
m.unregister()
|
595
scripts-blender/addons/lattice_magic/camera_lattice.py
Normal file
595
scripts-blender/addons/lattice_magic/camera_lattice.py
Normal file
@ -0,0 +1,595 @@
|
||||
# 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.
|
||||
|
||||
# TODO:
|
||||
# 3D Lattices: Need to have a distance, thickness and Z resolution parameter.
|
||||
|
||||
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
|
||||
|
||||
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 CAMLAT_OT_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 CAMLAT_OT_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
|
||||
return len(scene.lattice_slots) > 0
|
||||
|
||||
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 CAMLAT_OT_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
|
||||
return len(scene.lattice_slots) > 1
|
||||
|
||||
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 CAMLAT_OT_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]
|
||||
|
||||
return active_slot.collection and active_slot.camera and not active_slot.lattice
|
||||
|
||||
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
|
||||
|
||||
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 CAMLAT_OT_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]
|
||||
|
||||
return active_slot.lattice
|
||||
|
||||
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 CAMLAT_OT_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 CAMLAT_OT_ShapeKey_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 CAMLAT_OT_ShapeKey_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 CAMLAT_PT_Main(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(CAMLAT_OT_Add.bl_idname, text="", icon='ADD')
|
||||
remove_op = col.operator(CAMLAT_OT_Remove.bl_idname, text="", icon='REMOVE')
|
||||
remove_op.index = active_index
|
||||
col.separator()
|
||||
move_up_op = col.operator(CAMLAT_OT_Move.bl_idname, text="", icon='TRIA_UP')
|
||||
move_up_op.direction='UP'
|
||||
move_down_op = col.operator(CAMLAT_OT_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(CAMLAT_OT_Generate.bl_idname, icon='OUTLINER_OB_LATTICE')
|
||||
else:
|
||||
layout.operator(CAMLAT_OT_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(CAMLAT_OT_ShapeKey_Zero_All.bl_idname, text="", icon='RADIOBUT_OFF')
|
||||
row.operator(CAMLAT_OT_ShapeKey_Keyframe_All.bl_idname, text="", icon='HANDLETYPE_FREE_VEC')
|
||||
row.separator()
|
||||
prefs = context.preferences.addons[__package__].preferences
|
||||
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(CAMLAT_OT_ShapeKey_Add.bl_idname, text="", icon='ADD')
|
||||
remove_op = col.operator('object.shape_key_remove', text="", icon='REMOVE')
|
||||
|
||||
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 = context.preferences.addons[__package__].preferences
|
||||
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
|
||||
|
||||
classes = [
|
||||
LatticeSlot
|
||||
,CAMLAT_UL_lattice_slots
|
||||
|
||||
,CAMLAT_OT_Add
|
||||
,CAMLAT_OT_Remove
|
||||
,CAMLAT_OT_Move
|
||||
|
||||
,CAMLAT_OT_Generate
|
||||
,CAMLAT_OT_Delete
|
||||
,CAMLAT_OT_ShapeKey_Add
|
||||
|
||||
,CAMLAT_OT_ShapeKey_Zero_All
|
||||
,CAMLAT_OT_ShapeKey_Keyframe_All
|
||||
|
||||
,CAMLAT_PT_Main
|
||||
]
|
||||
|
||||
def register():
|
||||
from bpy.utils import register_class
|
||||
for c in classes:
|
||||
register_class(c)
|
||||
|
||||
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():
|
||||
from bpy.utils import unregister_class
|
||||
for c in reversed(classes):
|
||||
unregister_class(c)
|
||||
|
||||
del bpy.types.Scene.lattice_slots
|
||||
del bpy.types.Scene.active_lattice_index
|
||||
|
||||
bpy.app.handlers.frame_change_post.remove(camera_lattice_frame_change)
|
BIN
scripts-blender/addons/lattice_magic/docs/camera_lattice.gif
Normal file
BIN
scripts-blender/addons/lattice_magic/docs/camera_lattice.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 MiB |
BIN
scripts-blender/addons/lattice_magic/docs/lattice_magic.png
Normal file
BIN
scripts-blender/addons/lattice_magic/docs/lattice_magic.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
BIN
scripts-blender/addons/lattice_magic/docs/tweak_lattice.gif
Normal file
BIN
scripts-blender/addons/lattice_magic/docs/tweak_lattice.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.3 MiB |
90
scripts-blender/addons/lattice_magic/operators.py
Normal file
90
scripts-blender/addons/lattice_magic/operators.py
Normal file
@ -0,0 +1,90 @@
|
||||
import bpy
|
||||
from bpy.props import FloatProperty
|
||||
from .utils import get_lattice_point_original_position
|
||||
|
||||
class LATTICE_OT_Reset(bpy.types.Operator):
|
||||
"""Reset selected lattice points to their default position"""
|
||||
bl_idname = "lattice.reset_points"
|
||||
bl_label = "Reset Lattice Points"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
factor: FloatProperty(name="Factor", min=0, max=1, default=1)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return len(context.selected_objects)>0 and context.mode=='EDIT_LATTICE'
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
layout.prop(self, 'factor', slider=True)
|
||||
|
||||
def execute(self, context):
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for ob in context.selected_objects:
|
||||
if ob.type!='LATTICE':
|
||||
continue
|
||||
|
||||
# Resetting shape key or Basis shape
|
||||
if ob.data.shape_keys:
|
||||
active_index = ob.active_shape_key_index
|
||||
key_blocks = ob.data.shape_keys.key_blocks
|
||||
active_block = key_blocks[active_index]
|
||||
basis_block = key_blocks[0]
|
||||
if active_index > 0:
|
||||
for i, skp in enumerate(active_block.data):
|
||||
if not ob.data.points[i].select: continue
|
||||
skp.co = skp.co.lerp(basis_block.data[i].co, self.factor)
|
||||
continue
|
||||
else:
|
||||
for i, skp in enumerate(active_block.data):
|
||||
if not ob.data.points[i].select: continue
|
||||
base = get_lattice_point_original_position(ob.data, i)
|
||||
# Resetting the Basis shape
|
||||
mix = basis_block.data[i].co.lerp(base, self.factor)
|
||||
basis_block.data[i].co = mix
|
||||
continue
|
||||
|
||||
# Otherwise, reset the actual points.
|
||||
for i in range(len(ob.data.points)):
|
||||
point = ob.data.points[i]
|
||||
if not point.select: continue
|
||||
base = get_lattice_point_original_position(ob.data, i)
|
||||
mix = point.co_deform.lerp(base, self.factor)
|
||||
point.co_deform = mix
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
return {'FINISHED'}
|
||||
|
||||
def draw_shape_key_reset(self, context):
|
||||
layout = self.layout
|
||||
ob = context.object
|
||||
if ob.type=='MESH':
|
||||
op = layout.operator('mesh.blend_from_shape', text='Reset Shape Key', icon='FILE_REFRESH')
|
||||
op.shape = ob.data.shape_keys.key_blocks[0].name
|
||||
op.blend = 1
|
||||
op.add = False
|
||||
else:
|
||||
layout.operator(LATTICE_OT_Reset.bl_idname, text="Reset Shape Key", icon='FILE_REFRESH')
|
||||
|
||||
def draw_lattice_reset(self, context):
|
||||
self.layout.operator(LATTICE_OT_Reset.bl_idname, text="Reset Point Positions", icon='FILE_REFRESH')
|
||||
|
||||
classes = [
|
||||
LATTICE_OT_Reset
|
||||
]
|
||||
|
||||
def register():
|
||||
from bpy.utils import register_class
|
||||
for c in classes:
|
||||
register_class(c)
|
||||
bpy.types.MESH_MT_shape_key_context_menu.append(draw_shape_key_reset)
|
||||
bpy.types.VIEW3D_MT_edit_lattice.append(draw_lattice_reset)
|
||||
|
||||
def unregister():
|
||||
from bpy.utils import unregister_class
|
||||
for c in reversed(classes):
|
||||
unregister_class(c)
|
||||
|
||||
bpy.types.MESH_MT_shape_key_context_menu.remove(draw_shape_key_reset)
|
||||
bpy.types.VIEW3D_MT_edit_lattice.remove(draw_lattice_reset)
|
702
scripts-blender/addons/lattice_magic/tweak_lattice.py
Normal file
702
scripts-blender/addons/lattice_magic/tweak_lattice.py
Normal file
@ -0,0 +1,702 @@
|
||||
# Another lattice addon, this time inspired by https://twitter.com/soyposmoderno/status/1307222594047758337
|
||||
|
||||
# This one lets you create an empty hooked up to a Lattice to deform all selected objects.
|
||||
# A root empty is also created that can be (manually) parented to a rig in order to use this for animation.
|
||||
|
||||
import bpy
|
||||
from bpy.props import FloatProperty, IntVectorProperty, FloatVectorProperty, BoolProperty, PointerProperty, StringProperty, EnumProperty
|
||||
from bpy.types import Operator, Object, VertexGroup, Scene, Collection, Modifier, Panel
|
||||
from typing import List, Tuple
|
||||
|
||||
from mathutils import Vector
|
||||
from rna_prop_ui import rna_idprop_ui_create
|
||||
|
||||
from .utils import clamp, get_lattice_vertex_index, simple_driver, bounding_box_center_of_objects
|
||||
|
||||
TWEAKLAT_COLL_NAME = 'Tweak Lattices'
|
||||
|
||||
|
||||
class TWEAKLAT_OT_Create(Operator):
|
||||
"""Create a lattice setup to deform selected objects"""
|
||||
bl_idname = "lattice.create_tweak_lattice"
|
||||
bl_label = "Create Tweak Lattice"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
resolution: IntVectorProperty(
|
||||
name="Resolution",
|
||||
default=(12, 12, 12),
|
||||
min=6,
|
||||
max=64
|
||||
)
|
||||
|
||||
location: EnumProperty(name="Location", items=[
|
||||
('CURSOR', "3D Cursor", "Create at the location and orientation of the 3D cursor."),
|
||||
('CENTER', "Center", "Create at the bounding box center of all selected objects."),
|
||||
('PARENT', "Parent", "Create at the location of the parent object or bone.")
|
||||
])
|
||||
radius: FloatProperty(
|
||||
name="Radius",
|
||||
description="Radius of influence of this lattice. Can be changed later",
|
||||
default=0.1,
|
||||
min=0.0001,
|
||||
max=1000,
|
||||
soft_max=2
|
||||
)
|
||||
parent_bone: StringProperty(
|
||||
name="Bone", description="Bone to use as parent")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
for ob in context.selected_objects:
|
||||
if ob.type == 'MESH':
|
||||
return True
|
||||
return False
|
||||
|
||||
def invoke(self, context, _event):
|
||||
parent_obj = context.object
|
||||
for m in parent_obj.modifiers:
|
||||
if m.type == 'ARMATURE' and m.object:
|
||||
parent_obj = m.object
|
||||
if self.parent_bone not in parent_obj.data.bones:
|
||||
self.parent_bone = ""
|
||||
break
|
||||
|
||||
context.scene.tweak_lattice_parent_ob = parent_obj
|
||||
|
||||
wm = context.window_manager
|
||||
return wm.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
layout.use_property_decorate = False
|
||||
|
||||
layout.prop(self, 'location', expand=True)
|
||||
layout.prop(self, 'radius', slider=True)
|
||||
layout.separator()
|
||||
|
||||
col = layout.column(align=True)
|
||||
col.prop(context.scene, 'tweak_lattice_parent_ob')
|
||||
|
||||
scene = context.scene
|
||||
if scene.tweak_lattice_parent_ob and scene.tweak_lattice_parent_ob.type == 'ARMATURE':
|
||||
col.prop_search(self, 'parent_bone',
|
||||
scene.tweak_lattice_parent_ob.data, 'bones')
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
|
||||
# Ensure a collection to organize all our objects in.
|
||||
coll = ensure_tweak_lattice_collection(context.scene)
|
||||
|
||||
# Create a lattice object at the 3D cursor.
|
||||
lattice_name = "LTC-Tweak"
|
||||
lattice = bpy.data.lattices.new(lattice_name)
|
||||
lattice_ob = bpy.data.objects.new(lattice_name, lattice)
|
||||
coll.objects.link(lattice_ob)
|
||||
lattice_ob.hide_viewport = True
|
||||
|
||||
# Set resolution
|
||||
lattice.points_u, lattice.points_v, lattice.points_w, = self.resolution
|
||||
lattice.points_u = clamp(lattice.points_u, 6, 64)
|
||||
lattice.points_v = clamp(lattice.points_v, 6, 64)
|
||||
lattice.points_w = clamp(lattice.points_w, 6, 64)
|
||||
|
||||
# Create a falloff vertex group
|
||||
vg = ensure_falloff_vgroup(lattice_ob, vg_name="Hook")
|
||||
|
||||
# Create an Empty at the 3D cursor
|
||||
hook_name = "Hook_"+lattice_ob.name
|
||||
hook = bpy.data.objects.new(hook_name, None)
|
||||
hook.empty_display_type = 'SPHERE'
|
||||
hook.empty_display_size = 0.5
|
||||
coll.objects.link(hook)
|
||||
|
||||
# Create some custom properties
|
||||
hook['Lattice'] = lattice_ob
|
||||
lattice_ob['Hook'] = hook
|
||||
hook['Multiplier'] = 1.0
|
||||
hook['Expression'] = 'x'
|
||||
|
||||
rna_idprop_ui_create(
|
||||
hook, "Tweak Lattice", default=1.0,
|
||||
min=0, max=1,
|
||||
description="Influence of this lattice on all of its target objects",
|
||||
)
|
||||
rna_idprop_ui_create(
|
||||
hook, "Radius", default=self.radius,
|
||||
min=0, soft_max=0.2, max=100,
|
||||
description="Size of the influenced area",
|
||||
)
|
||||
|
||||
# Create a Root Empty to parent both the hook and the lattice to.
|
||||
# This will allow pressing Ctrl+G/R/S on the hook to reset its transforms.
|
||||
root_name = "Root_" + hook.name
|
||||
root = bpy.data.objects.new(root_name, None)
|
||||
root['Hook'] = hook
|
||||
root.empty_display_type = 'CUBE'
|
||||
root.empty_display_size = 0.5
|
||||
if self.location == 'CENTER':
|
||||
meshes = [o for o in context.selected_objects if o.type == 'MESH']
|
||||
root.matrix_world.translation = bounding_box_center_of_objects(
|
||||
meshes)
|
||||
elif self.location == 'CURSOR':
|
||||
root.matrix_world = context.scene.cursor.matrix
|
||||
elif self.location == 'PARENT':
|
||||
matrix_of_parent = scene.tweak_lattice_parent_ob.matrix_world
|
||||
if self.parent_bone:
|
||||
matrix_of_parent = scene.tweak_lattice_parent_ob.matrix_world @ scene.tweak_lattice_parent_ob.pose.bones[
|
||||
self.parent_bone].matrix
|
||||
root.matrix_world = matrix_of_parent.copy()
|
||||
coll.objects.link(root)
|
||||
root.hide_viewport = True
|
||||
hook['Root'] = root
|
||||
|
||||
# Parent the root
|
||||
scene = context.scene
|
||||
matrix_backup = root.matrix_world.copy()
|
||||
root.parent = scene.tweak_lattice_parent_ob
|
||||
if root.parent and root.parent.type == 'ARMATURE':
|
||||
bone = root.parent.pose.bones.get(self.parent_bone)
|
||||
if bone:
|
||||
root.parent_type = 'BONE'
|
||||
root.parent_bone = bone.name
|
||||
root.matrix_world = matrix_backup
|
||||
|
||||
# Parent lattice and hook to root
|
||||
lattice_ob.parent = root
|
||||
|
||||
hook.parent = root
|
||||
|
||||
# Add Hook modifier to the lattice
|
||||
hook_mod = lattice_ob.modifiers.new(name="Hook", type='HOOK')
|
||||
hook_mod.object = hook
|
||||
hook_mod.vertex_group = vg.name
|
||||
|
||||
# Add Lattice modifier to the selected objects
|
||||
add_objects_to_lattice(hook, context.selected_objects)
|
||||
|
||||
# Set up Radius control.
|
||||
add_radius_constraint(hook, hook, root)
|
||||
add_radius_constraint(lattice_ob, hook, root)
|
||||
|
||||
root_drv = simple_driver(
|
||||
root, 'empty_display_size', hook, '["Radius"]')
|
||||
root_drv.expression = 'var/2'
|
||||
|
||||
# Deselect everything, select the hook and make it active
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
hook.select_set(True)
|
||||
context.view_layer.objects.active = hook
|
||||
|
||||
scene.tweak_lattice_parent_ob = None
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
||||
class TWEAKLAT_OT_Duplicate(Operator):
|
||||
"""Duplicate this Tweak Lattice set-up"""
|
||||
bl_idname = "lattice.duplicate_tweak_setup"
|
||||
bl_label = "Duplicate Tweak Lattice"
|
||||
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
|
||||
|
||||
def execute(self, context):
|
||||
hook, lattice, root = get_tweak_setup(context.object)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
affected_objects = get_objects_of_lattice(hook)
|
||||
|
||||
visibilities = {}
|
||||
for ob in [hook, lattice, root]:
|
||||
ob.hide_set(False)
|
||||
visibilities[ob] = ob.hide_viewport
|
||||
ob.hide_viewport = False
|
||||
if not ob.visible_get():
|
||||
self.report({'ERROR'}, f'Object "{ob.name}" could not be made visible, cancelling.')
|
||||
return {'CANCELLED'}
|
||||
ob.select_set(True)
|
||||
|
||||
context.view_layer.objects.active = hook
|
||||
|
||||
bpy.ops.object.duplicate()
|
||||
new_hook, new_lattice, new_root = get_tweak_setup(context.object)
|
||||
|
||||
for key, value in list(new_hook.items()):
|
||||
if key.startswith("object_"):
|
||||
del new_hook[key]
|
||||
|
||||
add_objects_to_lattice(new_hook, affected_objects)
|
||||
|
||||
# Restore visibilities
|
||||
for ob, new_ob in zip((hook, lattice, root), (new_hook, new_lattice, new_root)):
|
||||
ob.hide_viewport = new_ob.hide_viewport = visibilities[ob]
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class TWEAKLAT_OT_Falloff(Operator):
|
||||
"""Adjust falloff of the hook vertex group of a Tweak Lattice"""
|
||||
bl_idname = "lattice.tweak_lattice_adjust_falloww"
|
||||
bl_label = "Adjust Falloff"
|
||||
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
|
||||
|
||||
def update_falloff(self, context):
|
||||
if self.doing_invoke:
|
||||
return
|
||||
hook, lattice, _root = get_tweak_setup(context.object)
|
||||
ret = ensure_falloff_vgroup(
|
||||
lattice, 'Hook', multiplier=self.multiplier, expression=self.expression)
|
||||
self.is_expression_valid = ret != None
|
||||
if ret:
|
||||
hook['Expression'] = self.expression
|
||||
hook['Multiplier'] = self.multiplier
|
||||
|
||||
is_expression_valid: BoolProperty(
|
||||
name="Error",
|
||||
description="Used to notify user if their expression is invalid",
|
||||
default=True
|
||||
)
|
||||
# Actual parameters
|
||||
multiplier: FloatProperty(
|
||||
name="Multiplier",
|
||||
description="Multiplier on the weight values",
|
||||
default=1,
|
||||
update=update_falloff,
|
||||
min=0,
|
||||
soft_max=2
|
||||
)
|
||||
expression: StringProperty(
|
||||
name="Expression",
|
||||
default="x",
|
||||
description="Expression to calculate the weight values where 'x' is a 0-1 value representing a point's closeness to the lattice center",
|
||||
update=update_falloff,
|
||||
)
|
||||
|
||||
# Storage to share info between Invoke and Update
|
||||
lattice_start_scale: FloatVectorProperty()
|
||||
hook_start_scale: FloatVectorProperty()
|
||||
doing_invoke: BoolProperty(default=True)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
hook, lattice, root = get_tweak_setup(context.object)
|
||||
return hook and lattice and root
|
||||
|
||||
def invoke(self, context, event):
|
||||
hook, _lattice, _root = get_tweak_setup(context.object)
|
||||
self.multiplier = hook['Multiplier']
|
||||
self.hook_start_scale = hook.scale.copy()
|
||||
lattice_ob = hook['Lattice']
|
||||
self.lattice_start_scale = lattice_ob.scale.copy()
|
||||
if 'Expression' not in hook:
|
||||
# Back-comp for Tweak Lattices created with older versions of the add-on.
|
||||
hook['Expression'] = 'x'
|
||||
self.expression = hook['Expression']
|
||||
|
||||
self.doing_invoke = False
|
||||
wm = context.window_manager
|
||||
return wm.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
layout.use_property_decorate = False
|
||||
layout.prop(self, 'expression', text="Expression", slider=True)
|
||||
if not self.is_expression_valid:
|
||||
row = layout.row()
|
||||
row.alert = True
|
||||
row.label(text="Invalid expression.", icon='ERROR')
|
||||
|
||||
layout.prop(self, 'multiplier', text="Strength", slider=True)
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class TWEAKLAT_OT_Delete(Operator):
|
||||
"""Delete a tweak lattice setup with all its helper objects, drivers, etc"""
|
||||
bl_idname = "lattice.delete_tweak_lattice"
|
||||
bl_label = "Delete Tweak Lattice"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
hook, lattice, root = get_tweak_setup(context.object)
|
||||
return hook and lattice and root
|
||||
|
||||
def execute(self, context):
|
||||
hook, lattice, root = get_tweak_setup(context.object)
|
||||
|
||||
# Remove Lattice modifiers and their drivers.
|
||||
remove_all_objects_from_lattice(hook)
|
||||
|
||||
# Remove hook Action if exists.
|
||||
if hook.animation_data and hook.animation_data.action:
|
||||
bpy.data.actions.remove(hook.animation_data.action)
|
||||
|
||||
# Remove objects and Lattice datablock.
|
||||
bpy.data.objects.remove(hook)
|
||||
lattice_data = lattice.data
|
||||
bpy.data.objects.remove(lattice)
|
||||
bpy.data.lattices.remove(lattice_data)
|
||||
bpy.data.objects.remove(root)
|
||||
|
||||
# Remove the collection if it's empty.
|
||||
coll = bpy.data.collections.get(TWEAKLAT_COLL_NAME)
|
||||
if coll and len(coll.all_objects) == 0:
|
||||
bpy.data.collections.remove(coll)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class TWEAKLAT_OT_Add_Objects(Operator):
|
||||
"""Add selected objects to this tweak lattice"""
|
||||
bl_idname = "lattice.add_selected_objects"
|
||||
bl_label = "Add Selected Objects"
|
||||
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
hook, _lattice, _root = get_tweak_setup(context.object)
|
||||
if not hook:
|
||||
return False
|
||||
|
||||
values = hook.values()
|
||||
for sel_o in context.selected_objects:
|
||||
if sel_o == hook or sel_o.type != 'MESH':
|
||||
continue
|
||||
if sel_o not in values:
|
||||
return True
|
||||
return False
|
||||
|
||||
def execute(self, context):
|
||||
hook, _lattice, _root = get_tweak_setup(context.object)
|
||||
|
||||
# Add Lattice modifier to the selected objects
|
||||
add_objects_to_lattice(hook, context.selected_objects)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class TWEAKLAT_OT_Remove_Selected_Objects(Operator):
|
||||
"""Remove selected objects from this tweak lattice"""
|
||||
bl_idname = "lattice.remove_selected_objects"
|
||||
bl_label = "Remove Selected Objects"
|
||||
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
hook, _lattice, _root = get_tweak_setup(context.object)
|
||||
if not hook:
|
||||
return False
|
||||
|
||||
values = hook.values()
|
||||
for sel_o in context.selected_objects:
|
||||
if sel_o == hook or sel_o.type != 'MESH':
|
||||
continue
|
||||
if sel_o in values:
|
||||
return True
|
||||
return False
|
||||
|
||||
def execute(self, context):
|
||||
hook, _lattice, _root = get_tweak_setup(context.object)
|
||||
|
||||
# Add Lattice modifier to the selected objects
|
||||
remove_objects_from_lattice(hook, context.selected_objects)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class TWEAKLAT_OT_Remove_Object(Operator):
|
||||
"""Remove this object from the tweak lattice"""
|
||||
bl_idname = "lattice.remove_object"
|
||||
bl_label = "Remove Object"
|
||||
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
|
||||
|
||||
ob_pointer_prop_name: StringProperty(
|
||||
description="Name of the custom property that references the object that should be removed")
|
||||
|
||||
def execute(self, context):
|
||||
hook, _lattice, _root = get_tweak_setup(context.object)
|
||||
target = hook[self.ob_pointer_prop_name]
|
||||
|
||||
# Add Lattice modifier to the selected objects
|
||||
remove_objects_from_lattice(hook, [target])
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class TWEAKLAT_PT_Main(Panel):
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Lattice Magic'
|
||||
bl_label = "Tweak Lattice"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
hook, _lattice, _root = get_tweak_setup(context.object)
|
||||
|
||||
return context.object and context.object.type == 'MESH' or hook
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.use_property_split = True
|
||||
layout.use_property_decorate = False
|
||||
|
||||
hook, lattice, root = get_tweak_setup(context.object)
|
||||
|
||||
layout = layout.column()
|
||||
if not hook:
|
||||
layout.operator(TWEAKLAT_OT_Create.bl_idname,
|
||||
icon='OUTLINER_OB_LATTICE')
|
||||
return
|
||||
|
||||
layout.prop(hook, '["Tweak Lattice"]', slider=True, text="Influence")
|
||||
layout.prop(hook, '["Radius"]', slider=True)
|
||||
layout.operator(TWEAKLAT_OT_Falloff.bl_idname, text="Adjust Falloff")
|
||||
|
||||
layout.separator()
|
||||
layout.operator(TWEAKLAT_OT_Delete.bl_idname,
|
||||
text='Delete Tweak Lattice', icon='TRASH')
|
||||
layout.operator(TWEAKLAT_OT_Duplicate.bl_idname,
|
||||
text='Duplicate Tweak Lattice', icon='DUPLICATE')
|
||||
|
||||
layout.separator()
|
||||
layout.label(text="Helper Objects")
|
||||
lattice_row = layout.row()
|
||||
lattice_row.prop(hook, '["Lattice"]', text="Lattice")
|
||||
lattice_row.prop(lattice, 'hide_viewport',
|
||||
text="", emboss=False)
|
||||
|
||||
root_row = layout.row()
|
||||
root_row.prop(hook, '["Root"]', text="Root")
|
||||
root_row.prop(root, 'hide_viewport', text="", emboss=False)
|
||||
|
||||
layout.separator()
|
||||
layout.label(text="Parenting")
|
||||
col = layout.column()
|
||||
col.enabled = False
|
||||
col.prop(root, 'parent')
|
||||
if root.parent and root.parent.type == 'ARMATURE':
|
||||
col.prop(root, 'parent_bone', icon='BONE_DATA')
|
||||
|
||||
layout.separator()
|
||||
layout.label(text="Affected Objects")
|
||||
|
||||
num_to_add = 0
|
||||
for o in context.selected_objects:
|
||||
if o == hook or o.type != 'MESH':
|
||||
continue
|
||||
if o in hook.values():
|
||||
continue
|
||||
num_to_add += 1
|
||||
if num_to_add == 1:
|
||||
text = f"Add {o.name}"
|
||||
if num_to_add:
|
||||
if num_to_add > 1:
|
||||
text = f"Add {num_to_add} Objects"
|
||||
layout.operator(TWEAKLAT_OT_Add_Objects.bl_idname,
|
||||
icon='ADD', text=text)
|
||||
|
||||
layout.separator()
|
||||
num_to_remove = False
|
||||
for o in context.selected_objects:
|
||||
if o == hook or o.type != 'MESH':
|
||||
continue
|
||||
if o not in hook.values():
|
||||
continue
|
||||
num_to_remove += 1
|
||||
if num_to_remove == 1:
|
||||
text = f"Remove {o.name}"
|
||||
if num_to_remove:
|
||||
if num_to_remove > 1:
|
||||
text = f"Remove {num_to_remove} Objects"
|
||||
layout.operator(
|
||||
TWEAKLAT_OT_Remove_Selected_Objects.bl_idname, icon='REMOVE', text=text)
|
||||
|
||||
objects_and_keys = [(hook[key], key)
|
||||
for key in hook.keys() if "object_" in key]
|
||||
objects_and_keys.sort(key=lambda o_and_k: o_and_k[1])
|
||||
for ob, key in objects_and_keys:
|
||||
row = layout.row(align=True)
|
||||
row.prop(hook, f'["{key}"]', text="")
|
||||
mod = get_lattice_modifier_of_object(ob, lattice)
|
||||
row.prop_search(mod, 'vertex_group', ob,
|
||||
'vertex_groups', text="", icon='GROUP_VERTEX')
|
||||
op = row.operator(
|
||||
TWEAKLAT_OT_Remove_Object.bl_idname, text="", icon='X')
|
||||
op.ob_pointer_prop_name = key
|
||||
|
||||
|
||||
def get_tweak_setup(obj: Object) -> Tuple[Object, Object, Object]:
|
||||
"""Based on either the hook, lattice or root, return all three."""
|
||||
if not obj:
|
||||
return [None, None, None]
|
||||
|
||||
if obj.type == 'EMPTY':
|
||||
if 'Root' and 'Lattice' in obj:
|
||||
return obj, obj['Lattice'], obj['Root']
|
||||
elif 'Hook' in obj:
|
||||
return obj['Hook'], obj['Hook']['Lattice'], obj
|
||||
elif obj.type == 'LATTICE' and 'Hook' in obj:
|
||||
return obj['Hook'], obj, obj['Hook']['Root']
|
||||
|
||||
return [None, None, None]
|
||||
|
||||
|
||||
def ensure_tweak_lattice_collection(scene: Scene) -> Collection:
|
||||
coll = bpy.data.collections.get(TWEAKLAT_COLL_NAME)
|
||||
if not coll:
|
||||
coll = bpy.data.collections.new(TWEAKLAT_COLL_NAME)
|
||||
scene.collection.children.link(coll)
|
||||
|
||||
return coll
|
||||
|
||||
|
||||
def ensure_falloff_vgroup(
|
||||
lattice_ob: Object,
|
||||
vg_name="Group", multiplier=1, expression="x") -> VertexGroup:
|
||||
lattice = lattice_ob.data
|
||||
res_x, res_y, res_z = lattice.points_u, lattice.points_v, lattice.points_w
|
||||
|
||||
vg = lattice_ob.vertex_groups.get(vg_name)
|
||||
|
||||
center = Vector((res_x-1, res_y-1, res_z-1))/2
|
||||
max_res = max(res_x, res_y, res_z)
|
||||
|
||||
if not vg:
|
||||
vg = lattice_ob.vertex_groups.new(name=vg_name)
|
||||
for x in range(res_x-4):
|
||||
for y in range(res_y-4):
|
||||
for z in range(res_z-4):
|
||||
index = get_lattice_vertex_index(lattice, (x+2, y+2, z+2))
|
||||
|
||||
coord = Vector((x+2, y+2, z+2))
|
||||
distance_from_center = (coord-center).length
|
||||
distance_factor = 1 - (distance_from_center / max_res * 2)
|
||||
try:
|
||||
influence = eval(expression.replace(
|
||||
"x", "distance_factor"))
|
||||
except:
|
||||
return None
|
||||
|
||||
vg.add([index], influence * multiplier, 'REPLACE')
|
||||
return vg
|
||||
|
||||
|
||||
def add_radius_constraint(obj, hook, target):
|
||||
trans_con = obj.constraints.new(type='TRANSFORM')
|
||||
trans_con.target = target
|
||||
trans_con.map_to = 'SCALE'
|
||||
trans_con.mix_mode_scale = 'MULTIPLY'
|
||||
for prop in ['to_min_x_scale', 'to_min_y_scale', 'to_min_z_scale']:
|
||||
simple_driver(trans_con, prop, hook, '["Radius"]')
|
||||
return trans_con
|
||||
|
||||
|
||||
def get_objects_of_lattice(hook: Object) -> List[Object]:
|
||||
objs = []
|
||||
for key, value in hook.items():
|
||||
if key.startswith("object_") and value:
|
||||
objs.append(value)
|
||||
|
||||
return objs
|
||||
|
||||
|
||||
def get_lattice_modifier_of_object(obj, lattice) -> Modifier:
|
||||
"""Find the lattice modifier on the object that uses this lattice"""
|
||||
for m in obj.modifiers:
|
||||
if m.type == 'LATTICE' and m.object == lattice:
|
||||
return m
|
||||
|
||||
|
||||
def add_objects_to_lattice(
|
||||
hook: Object,
|
||||
objects: List[Object]):
|
||||
lattice_ob = hook['Lattice']
|
||||
|
||||
for i, o in enumerate(objects):
|
||||
o.select_set(False)
|
||||
if o.type != 'MESH' or o in hook.values():
|
||||
continue
|
||||
m = o.modifiers.new(name=lattice_ob.name, type='LATTICE')
|
||||
m.object = lattice_ob
|
||||
|
||||
# Make sure the property name is available.
|
||||
offset = 0
|
||||
while "object_"+str(offset) in hook:
|
||||
offset += 1
|
||||
hook["object_"+str(i+offset)] = o
|
||||
|
||||
# Add driver to the modifier influence.
|
||||
simple_driver(m, 'strength', hook, '["Tweak Lattice"]')
|
||||
|
||||
|
||||
def remove_object_from_lattice(hook: Object, obj: Object):
|
||||
"""Cleanly remove an object from a Tweak Lattice set-up's influence."""
|
||||
hook, lattice, root = get_tweak_setup(hook)
|
||||
|
||||
# Remove the custom property pointing from the Hook to the Object.
|
||||
for key, value in list(hook.items()):
|
||||
if value == obj:
|
||||
del hook[key]
|
||||
break
|
||||
|
||||
# Remove the Lattice modifier (and its driver) deforming the Object.
|
||||
for m in obj.modifiers:
|
||||
if m.type != 'LATTICE':
|
||||
continue
|
||||
if m.object == lattice:
|
||||
m.driver_remove('strength')
|
||||
obj.modifiers.remove(m)
|
||||
break
|
||||
|
||||
|
||||
def remove_objects_from_lattice(hook: Object, objects_to_remove: List[Object]) -> List[Object]:
|
||||
"""Cleanly remove several objects from a Tweak Lattice set-up's influence."""
|
||||
objs_removed = []
|
||||
for key, value in list(hook.items()):
|
||||
if value in objects_to_remove:
|
||||
remove_object_from_lattice(hook, value)
|
||||
objs_removed.append(value)
|
||||
|
||||
return objs_removed
|
||||
|
||||
|
||||
def remove_all_objects_from_lattice(hook: Object) -> List[Object]:
|
||||
"""Cleanly remove all objects from a Tweak Lattice set-up's influence."""
|
||||
objs_to_remove = []
|
||||
for key, value in list(hook.items()):
|
||||
if key.startswith("object_"):
|
||||
objs_to_remove.append(value)
|
||||
|
||||
return remove_objects_from_lattice(hook, objs_to_remove)
|
||||
|
||||
|
||||
classes = [
|
||||
TWEAKLAT_OT_Create,
|
||||
TWEAKLAT_OT_Duplicate,
|
||||
TWEAKLAT_OT_Delete,
|
||||
TWEAKLAT_OT_Falloff,
|
||||
TWEAKLAT_OT_Add_Objects,
|
||||
TWEAKLAT_OT_Remove_Selected_Objects,
|
||||
TWEAKLAT_OT_Remove_Object,
|
||||
TWEAKLAT_PT_Main
|
||||
]
|
||||
|
||||
|
||||
def register():
|
||||
from bpy.utils import register_class
|
||||
for c in classes:
|
||||
register_class(c)
|
||||
|
||||
Scene.tweak_lattice_parent_ob = PointerProperty(
|
||||
type=Object, name="Parent")
|
||||
|
||||
|
||||
def unregister():
|
||||
from bpy.utils import unregister_class
|
||||
for c in reversed(classes):
|
||||
unregister_class(c)
|
||||
|
||||
del Scene.tweak_lattice_parent_ob
|
104
scripts-blender/addons/lattice_magic/utils.py
Normal file
104
scripts-blender/addons/lattice_magic/utils.py
Normal file
@ -0,0 +1,104 @@
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
from typing import List, Tuple
|
||||
|
||||
def clamp(val, _min=0, _max=1) -> float or int:
|
||||
if val < _min:
|
||||
return _min
|
||||
if val > _max:
|
||||
return _max
|
||||
return val
|
||||
|
||||
def get_lattice_vertex_index(lattice: bpy.types.Lattice, xyz: List[int], do_clamp=True) -> int:
|
||||
"""Get the index of a lattice vertex based on its position on the XYZ axes."""
|
||||
|
||||
# The lattice vertex indicies start in the -Y, -X, -Z corner,
|
||||
# increase on X+, then moves to the next row on Y+, then moves up on Z+.
|
||||
res_x, res_y, res_z = lattice.points_u, lattice.points_v, lattice.points_w
|
||||
x, y, z = xyz[:]
|
||||
if do_clamp:
|
||||
x = clamp(x, 0, res_x)
|
||||
y = clamp(y, 0, res_y)
|
||||
z = clamp(z, 0, res_z)
|
||||
|
||||
assert x < res_x and y < res_y and z < res_z, "Error: Lattice vertex xyz index out of bounds"
|
||||
|
||||
index = (z * res_y*res_x) + (y * res_x) + x
|
||||
return index
|
||||
|
||||
def get_lattice_vertex_xyz_position(lattice: bpy.types.Lattice, index: int) -> (int, int, int):
|
||||
res_x, res_y, res_z = lattice.points_u, lattice.points_v, lattice.points_w
|
||||
|
||||
x = 0
|
||||
remaining = index
|
||||
z = int(remaining / (res_x*res_y))
|
||||
remaining -= z*(res_x*res_y)
|
||||
y = int(remaining / res_x)
|
||||
remaining -= y*res_x
|
||||
x = remaining # Maybe need to add or subtract 1 here?
|
||||
|
||||
return (x, y, z)
|
||||
|
||||
def get_lattice_point_original_position(lattice: bpy.types.Lattice, index: int) -> Vector:
|
||||
"""Reset a lattice vertex to its original position."""
|
||||
start_vec = Vector((-0.5, -0.5, -0.5))
|
||||
if lattice.points_u == 1:
|
||||
start_vec[0] = 0
|
||||
if lattice.points_v == 1:
|
||||
start_vec[1] = 0
|
||||
if lattice.points_w == 1:
|
||||
start_vec[2] = 0
|
||||
|
||||
unit_u = 1/(lattice.points_u-0.99)
|
||||
unit_v = 1/(lattice.points_v-0.99)
|
||||
unit_w = 1/(lattice.points_w-0.99)
|
||||
|
||||
unit_vec = Vector((unit_u, unit_v, unit_w))
|
||||
xyz_vec = Vector(get_lattice_vertex_xyz_position(lattice, index))
|
||||
|
||||
return start_vec + xyz_vec*unit_vec
|
||||
|
||||
def simple_driver(owner: bpy.types.ID, driver_path: str, target_ob: bpy.types.Object, data_path: str, array_index=-1) -> bpy.types.Driver:
|
||||
if array_index > -1:
|
||||
owner.driver_remove(driver_path, array_index)
|
||||
driver = owner.driver_add(driver_path, array_index).driver
|
||||
else:
|
||||
owner.driver_remove(driver_path)
|
||||
driver = owner.driver_add(driver_path).driver
|
||||
|
||||
driver.expression = 'var'
|
||||
var = driver.variables.new()
|
||||
var.targets[0].id = target_ob
|
||||
var.targets[0].data_path = data_path
|
||||
|
||||
return driver
|
||||
|
||||
|
||||
def bounding_box(points) -> Tuple[Vector, Vector]:
|
||||
""" Return two vectors representing the lowest and highest coordinates of
|
||||
a the bounding box of the passed points.
|
||||
"""
|
||||
|
||||
lowest = points[0].copy()
|
||||
highest = points[0].copy()
|
||||
for p in points:
|
||||
for i in range(len(p)):
|
||||
if p[i] < lowest[i]:
|
||||
lowest[i] = p[i]
|
||||
if p[i] > highest[i]:
|
||||
highest[i] = p[i]
|
||||
|
||||
return lowest, highest
|
||||
|
||||
def bounding_box_center(points) -> Vector:
|
||||
"""Find the bounding box center of some points."""
|
||||
bbox_low, bbox_high = bounding_box(points)
|
||||
return bbox_low + (bbox_high-bbox_low)/2
|
||||
|
||||
def bounding_box_center_of_objects(objects) -> Vector:
|
||||
"""Find the bounding box center of some objects."""
|
||||
all_points = []
|
||||
for o in objects:
|
||||
for p in o.bound_box:
|
||||
all_points.append(o.matrix_world @ Vector(p))
|
||||
return bounding_box_center(all_points)
|
Loading…
Reference in New Issue
Block a user