44
55Author: namakoshiro
66Created: 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
99Blender Version: 2.80 → 4.32
1010
1111Functions:
1212Add Empty Shapekey Below: Add a new empty shapekey below the selected one.
1313Add Shapekey from Mix Below: Capture the current mix as a new shapekey below the selected one.
1414Split Shapekey L/R: Split a shapekey into left (.L) and right (.R) versions below the selected one.
1515Mirror 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
1819bl_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+
372626class 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
436691def 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
444700def 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