Skip to content

Commit 70154a6

Browse files
authored
feat: Update to version 1.1.1
- Add experimental function: Smartly split mouth shapekey into L/R
1 parent 42f1ddd commit 70154a6

File tree

1 file changed

+268
-11
lines changed

1 file changed

+268
-11
lines changed

blender-shapekey-tools.py

Lines changed: 268 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,22 @@
44
55
Author: namakoshiro
66
Created: 2025/1/11
7-
Version: 1.1.0
8-
Last Updated: 2025/1/13
7+
Version: 1.1.1
8+
Last Updated: 2025/1/14
99
Blender Version: 2.80 → 4.32
1010
1111
Functions:
1212
Add Empty Shapekey Below: Add a new empty shapekey below the selected one.
1313
Add Shapekey from Mix Below: Capture the current mix as a new shapekey below the selected one.
1414
Split Shapekey L/R: Split a shapekey into left (.L) and right (.R) versions below the selected one.
1515
Mirror Shapekey: Generate a mirrored version of the selected shapekey below the selected one.
16+
Smart Split Mouth L/R: Experimental: Smartly split mouth shapekey into L/R.
1617
"""
1718

1819
bl_info = {
1920
"name": "blender-shapekey-tools",
2021
"author": "namakoshiro",
21-
"version": (1, 1, 0),
22+
"version": (1, 1, 1),
2223
"blender": (2, 80, 0),
2324
"location": "View3D > Sidebar > Shapekey",
2425
"description": "A small tool for one click to split or merge L/R shapekeys, and generate new shapekey below the selected item",
@@ -369,6 +370,259 @@ def execute(self, context):
369370
self.report({'ERROR'}, f"Failed to mirror shapekey: {str(e)}")
370371
return {'CANCELLED'}
371372

373+
class ADD_OT_shapekey_smart_split_mouth_lr(Operator):
374+
"""Smart split shapekey into weighted left and right versions
375+
376+
This operator creates two new shapekeys (.L and .R) from the selected shapekey,
377+
with custom weight distribution based on vertex X positions. The deformation
378+
on Y and Z axes is weighted using a custom curve, while X axis deformation
379+
remains unchanged. Only affects the main face mesh, excluding teeth and tongue.
380+
"""
381+
bl_idname = "object.add_shapekey_smart_split_mouth_lr"
382+
bl_label = "Smart Split Mouth L/R"
383+
bl_description = "Experimental: Smartly split mouth shapekey into L/R"
384+
bl_options = {'REGISTER', 'UNDO'}
385+
386+
# Add configurable properties
387+
threshold: bpy.props.FloatProperty(
388+
name="Middle Threshold",
389+
description="Threshold for determining middle vertices",
390+
default=0.0001,
391+
min=0.0,
392+
max=1.0,
393+
precision=4
394+
)
395+
396+
@classmethod
397+
def poll(cls, context):
398+
obj = context.active_object
399+
return (obj and obj.type == 'MESH' and obj.data.shape_keys
400+
and obj.active_shape_key_index > 0)
401+
402+
def find_mesh_islands(self, obj):
403+
"""Find all separate mesh islands and return their vertex indices
404+
405+
Returns:
406+
List of lists, each containing vertex indices for one island
407+
"""
408+
# Create BMesh
409+
bm = bmesh.new()
410+
bm.from_mesh(obj.data)
411+
bm.verts.ensure_lookup_table()
412+
413+
islands = []
414+
unvisited = set(range(len(bm.verts)))
415+
416+
while unvisited:
417+
# Start new island
418+
start = unvisited.pop()
419+
island = {start}
420+
to_visit = {start}
421+
422+
# Expand island
423+
while to_visit:
424+
current = to_visit.pop()
425+
vert = bm.verts[current]
426+
427+
# Add connected vertices
428+
for edge in vert.link_edges:
429+
other = edge.other_vert(vert)
430+
other_index = other.index
431+
if other_index in unvisited:
432+
unvisited.remove(other_index)
433+
island.add(other_index)
434+
to_visit.add(other_index)
435+
436+
islands.append(list(island))
437+
438+
bm.free()
439+
return islands
440+
441+
def get_island_bounds(self, obj, island_verts):
442+
"""Get the bounding box of an island"""
443+
coords = [obj.data.vertices[i].co for i in island_verts]
444+
min_x = min(co.x for co in coords)
445+
max_x = max(co.x for co in coords)
446+
min_y = min(co.y for co in coords)
447+
max_y = max(co.y for co in coords)
448+
min_z = min(co.z for co in coords)
449+
max_z = max(co.z for co in coords)
450+
451+
volume = (max_x - min_x) * (max_y - min_y) * (max_z - min_z)
452+
return {
453+
'volume': volume,
454+
'vert_count': len(island_verts),
455+
'verts': island_verts,
456+
'bounds': (min_x, max_x, min_y, max_y, min_z, max_z)
457+
}
458+
459+
def find_face_mesh(self, obj):
460+
"""Find the main face mesh by identifying the largest connected mesh"""
461+
islands = self.find_mesh_islands(obj)
462+
463+
# Get bounds and metrics for each island
464+
island_data = [self.get_island_bounds(obj, island) for island in islands]
465+
466+
# Sort by volume and vertex count
467+
island_data.sort(key=lambda x: (x['volume'], x['vert_count']), reverse=True)
468+
469+
# Return vertices of the largest island (face mesh)
470+
return set(island_data[0]['verts'])
471+
472+
def calculate_custom_weight(self, position):
473+
"""Calculate weight using custom curve
474+
475+
Args:
476+
position: Value from 0 to 1 representing position from left to right
477+
478+
Returns:
479+
Weight value from 0 to 1 using a smooth bezier curve:
480+
- Almost flat in the first 30% (very slow decay)
481+
- Extremely rapid decay after 30%, approaching 0 quickly
482+
"""
483+
# Bezier curve control points
484+
p0 = 0.0 # Start point
485+
p1 = 0.99 # First control point (moved to 0.99 for even flatter first 30%)
486+
p2 = 0.65 # Second control point (moved to 0.65 for extremely steep decay)
487+
p3 = 1.0 # End point
488+
489+
# Cubic Bezier curve calculation
490+
t = position
491+
t2 = t * t
492+
t3 = t2 * t
493+
mt = 1 - t
494+
mt2 = mt * mt
495+
mt3 = mt2 * mt
496+
497+
# Calculate weight using cubic Bezier curve
498+
# Moving p1 extremely close to 1.0 makes the first 30% almost completely flat
499+
# Moving p2 far from 1.0 creates an extremely steep drop-off
500+
weight = mt3 * 1.0 + 3 * mt2 * t * p1 + 3 * mt * t2 * p2 + t3 * 0.0
501+
502+
return weight
503+
504+
def find_deformed_vertices(self, basis_key, shape_key, face_verts):
505+
"""Find vertices that are modified by the shapekey and are part of the face mesh"""
506+
deformed_verts = []
507+
for i, (basis_vert, shape_vert) in enumerate(zip(basis_key.data, shape_key.data)):
508+
if i in face_verts and (basis_vert.co - shape_vert.co).length > self.threshold:
509+
deformed_verts.append(i)
510+
return deformed_verts
511+
512+
def get_x_range(self, vertices, deformed_indices, source_key):
513+
"""Get the X coordinate range of deformed vertices in their deformed state"""
514+
x_coords = [source_key.data[i].co.x for i in deformed_indices]
515+
return min(x_coords), max(x_coords)
516+
517+
def create_weighted_side_shapekey(self, context, source_key, side, deformed_indices, x_min, x_max, face_verts):
518+
"""Create a new weighted side shapekey"""
519+
obj = context.active_object
520+
basis = obj.data.shape_keys.reference_key
521+
522+
# Create new shapekey
523+
new_key = obj.shape_key_add(name=f"{source_key.name}.{side}")
524+
new_key.value = 0
525+
526+
# Calculate weights and apply deformation
527+
x_range = x_max - x_min
528+
for i, v in enumerate(new_key.data):
529+
if i in deformed_indices:
530+
# Get relative position in X range using deformed position
531+
rel_pos = (source_key.data[i].co.x - x_min) / x_range
532+
533+
# For right side, invert the position
534+
if side == 'R':
535+
rel_pos = 1 - rel_pos
536+
537+
# Calculate weight using custom curve
538+
weight = self.calculate_custom_weight(rel_pos)
539+
540+
# Get the deformation vector
541+
deform = source_key.data[i].co - basis.data[i].co
542+
543+
# Apply weighted deformation
544+
weighted_deform = Vector((
545+
deform.x, # X unchanged
546+
deform.y * weight, # Y weighted
547+
deform.z * weight # Z weighted
548+
))
549+
550+
# Apply the weighted deformation
551+
v.co = basis.data[i].co + weighted_deform
552+
else:
553+
# Copy the original shape for non-face vertices
554+
v.co = source_key.data[i].co.copy() if i not in face_verts else basis.data[i].co
555+
556+
def execute(self, context):
557+
try:
558+
context.window_manager.progress_begin(0, 100)
559+
560+
obj = context.active_object
561+
active_key = obj.active_shape_key
562+
if not active_key:
563+
self.report({'ERROR'}, "No active shapekey selected")
564+
return {'CANCELLED'}
565+
566+
# Find face mesh vertices
567+
face_verts = self.find_face_mesh(obj)
568+
569+
# Store original index and values
570+
active_index = obj.active_shape_key_index
571+
original_values = {sk.name: sk.value for sk in obj.data.shape_keys.key_blocks}
572+
573+
# Find deformed vertices (only in face mesh)
574+
basis = obj.data.shape_keys.reference_key
575+
deformed_indices = self.find_deformed_vertices(basis, active_key, face_verts)
576+
577+
if not deformed_indices:
578+
self.report({'ERROR'}, "No deformed vertices found in face mesh")
579+
return {'CANCELLED'}
580+
581+
context.window_manager.progress_update(30)
582+
583+
# Get X range of deformed vertices using deformed positions
584+
x_min, x_max = self.get_x_range(obj.data.vertices, deformed_indices, active_key)
585+
586+
# Create right side first (will end up below left side)
587+
self.create_weighted_side_shapekey(context, active_key, 'R', deformed_indices, x_min, x_max, face_verts)
588+
589+
# Move the right side shapekey up
590+
shapekeys = obj.data.shape_keys.key_blocks
591+
for i in range(len(shapekeys) - 1, active_index + 1, -1):
592+
bpy.context.object.active_shape_key_index = i
593+
bpy.ops.object.shape_key_move(type='UP')
594+
595+
context.window_manager.progress_update(60)
596+
597+
# Create left side
598+
self.create_weighted_side_shapekey(context, active_key, 'L', deformed_indices, x_min, x_max, face_verts)
599+
600+
# Move the left side shapekey up
601+
shapekeys = obj.data.shape_keys.key_blocks
602+
for i in range(len(shapekeys) - 1, active_index + 1, -1):
603+
bpy.context.object.active_shape_key_index = i
604+
bpy.ops.object.shape_key_move(type='UP')
605+
606+
context.window_manager.progress_update(80)
607+
608+
# Reset original shapekey value and restore other values
609+
active_key.value = 0
610+
for sk in shapekeys:
611+
if sk != active_key and sk.name in original_values:
612+
sk.value = original_values[sk.name]
613+
614+
# Select the left side shapekey
615+
obj.active_shape_key_index = active_index + 1
616+
617+
context.window_manager.progress_end()
618+
return {'FINISHED'}
619+
620+
except Exception as e:
621+
if context.window_manager.progress_is_running:
622+
context.window_manager.progress_end()
623+
self.report({'ERROR'}, f"Failed to smart split shapekey: {str(e)}")
624+
return {'CANCELLED'}
625+
372626
class VIEW3D_PT_shapekey_tools(Panel):
373627
"""Panel for Shapekey Tools
374628
@@ -405,7 +659,7 @@ def draw(self, context):
405659
box = layout.box()
406660
col = box.column(align=True)
407661
col.operator("object.add_shapekey_below", icon='ADD')
408-
col.operator("object.add_shapekey_from_mix_below", icon='SCULPTMODE_HLT')
662+
col.operator("object.add_shapekey_from_mix_below", icon='ADD')
409663

410664
# Separator between button groups
411665
layout.separator()
@@ -416,21 +670,22 @@ def draw(self, context):
416670
col.operator("object.add_shapekey_split_lr", icon='MOD_MIRROR')
417671
col.operator("object.add_shapekey_mirror", icon='ARROW_LEFTRIGHT')
418672

419-
# Add note at bottom
673+
# Separator before experimental section
420674
layout.separator()
675+
676+
# Experimental Operations
421677
box = layout.box()
422-
col = box.column()
423-
col.scale_y = 0.8 # Slightly reduce vertical spacing
424-
col.label(text="New shapes will be", icon='INFO')
425-
col.label(text="added below selected")
678+
box.label(text="Experimental", icon='ERROR')
679+
col = box.column(align=True)
680+
col.operator("object.add_shapekey_smart_split_mouth_lr", icon='MOD_MIRROR')
426681

427682
# Add version information
428683
layout.separator()
429684
box = layout.box()
430685
col = box.column()
431686
col.scale_y = 0.8
432-
col.label(text="Version: 1.1.0")
433-
col.label(text="Last Updated: 2025/1/13")
687+
col.label(text="Version: 1.1.1")
688+
col.label(text="Last Updated: 2025/1/14")
434689
col.label(text="Blender: 2.80 → 4.32")
435690

436691
def register():
@@ -439,11 +694,13 @@ def register():
439694
bpy.utils.register_class(ADD_OT_shapekey_from_mix_below)
440695
bpy.utils.register_class(ADD_OT_shapekey_split_lr)
441696
bpy.utils.register_class(ADD_OT_shapekey_mirror)
697+
bpy.utils.register_class(ADD_OT_shapekey_smart_split_mouth_lr)
442698
bpy.utils.register_class(VIEW3D_PT_shapekey_tools)
443699

444700
def unregister():
445701
"""Unregister all classes"""
446702
bpy.utils.unregister_class(VIEW3D_PT_shapekey_tools)
703+
bpy.utils.unregister_class(ADD_OT_shapekey_smart_split_mouth_lr)
447704
bpy.utils.unregister_class(ADD_OT_shapekey_mirror)
448705
bpy.utils.unregister_class(ADD_OT_shapekey_split_lr)
449706
bpy.utils.unregister_class(ADD_OT_shapekey_from_mix_below)

0 commit comments

Comments
 (0)