diff --git a/__init__.py b/__init__.py index ce839d4..e236694 100755 --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "gp interpolate", "author": "Christophe Seux, Samuel Bernou", - "version": (0, 1, 1), + "version": (0, 1, 2), "blender": (3, 6, 0), "location": "Sidebar > Gpencil Tab > Interpolate", "description": "Interpolate Grease pencil strokes over 3D", diff --git a/interpolate_strokes/operators.py b/interpolate_strokes/operators.py index f63c007..08b5d29 100644 --- a/interpolate_strokes/operators.py +++ b/interpolate_strokes/operators.py @@ -9,7 +9,9 @@ from gp_interpolate.utils import (matrix_transform, intersect_with_tesselated_plane, triangle_normal, search_square, - get_gp_draw_plane) + get_gp_draw_plane, + create_plane, + attr_set) from mathutils.geometry import (barycentric_transform, intersect_point_tri, @@ -53,6 +55,205 @@ def following_key(forward=True): return int(new) + +## TODO: add bake animation to empty for later GP layer parenting + +class GP_OT_interpolate_stroke(bpy.types.Operator): + bl_idname = "gp.interpolate_stroke" + bl_label = "Interpolate Stroke" + bl_description = 'Interpolate Stroke' + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + if context.active_object and context.object.type == 'GPENCIL'\ + and context.mode in ('EDIT_GPENCIL', 'SCULPT_GPENCIL', 'PAINT_GPENCIL'): + return True + cls.poll_message_set("Need a Grease pencil object in Edit or Sculpt mode") + return False + + @classmethod + def description(cls, context, properties): + if properties.next: + return f"Interpolate Stroke Forward" + else: + return f"Interpolate Stroke Backward" + + next : bpy.props.BoolProperty(name='Next', default=True, options={'SKIP_SAVE'}) + + def execute(self, context): + settings = context.scene.gp_interpo_settings + scn = bpy.context.scene + + # auto_key_status = context.tool_settings.use_keyframe_insert_auto + # context.tool_settings.use_keyframe_insert_auto = True + + ## Determine on what key to jump + frame_to_jump = following_key(forward=self.next) + if frame_to_jump is None: + self.report({'WARNING'}, 'No keyframe available in this direction') + return {'CANCELLED'} + + gp = context.object + + matrix = np.array(gp.matrix_world, dtype='float64')#.inverted() + + col = settings.target_collection + if not col: + col = scn.collection + + origin = np.array(scn.camera.matrix_world.to_translation(), 'float64') + # print('----') + + tgt_strokes = [s for s in gp.data.layers.active.active_frame.strokes if s.select] + + ## If nothing selected in sculpt/paint, Select all before triggering + if not tgt_strokes and context.mode in ('SCULPT_GPENCIL', 'PAINT_GPENCIL'): + for s in gp.data.layers.active.active_frame.strokes: + s.select = True + tgt_strokes = gp.data.layers.active.active_frame.strokes + + if not tgt_strokes: + self.report({'ERROR'}, 'No stroke selected !') + return {'CANCELLED'} + + included_cols = [c.name for c in gp.users_collection] + + if settings.method == 'BONE': + ## Follow Bone method (WIP) + if not settings.target_rig or not settings.target_bone: + self.report({'ERROR'}, 'No Bone selected') + return {'CANCELLED'} + + included_cols.append('interpolation_tool') + + ## ensure collection and plane exists + # get/create collection + col = bpy.data.collections.get('interpolation_tool') + if not col: + col = bpy.data.collections.new('interpolation_tool') + + if col.name not in bpy.context.scene.collection.children: + bpy.context.scene.collection.children.link(col) + col.hide_viewport = True + + # get/create meshplane + plane = bpy.data.objects.get('interpolation_plane') + if not plane: + plane = create_plane(name='interpolation_plane') + plane.select_set(False) + + if plane.name not in col.objects: + col.objects.link(plane) + ## TODO: Ensure the plane is not animated! + + else: + # Geometry mode + if col != context.scene.collection: + included_cols.append(col.name) + ## Maybe include a plane just behing geo ? probably bad idea + + ## Prepare context manager + store_list = [ + # (context.view_layer.objects, 'active', gp), + (context.tool_settings, 'use_keyframe_insert_auto', True), + # (bpy.context.scene.render, 'simplify_subdivision', 0), + ] + + for vlc in context.view_layer.layer_collection.children: + store_list.append( + (vlc, 'exclude', vlc.name not in included_cols), + # (vlc, 'hide_viewport', vlc.name not in included_cols), # viewport viz + ) + + with attr_set(store_list): + if settings.method == 'BONE': + ## replace plane + bone_plane = plane_on_bone(settings.target_rig.pose.bones.get(settings.target_bone), + arm=settings.target_rig, + set_rotation=settings.use_bone_rotation, + mesh=True) + ## Set collection visibility + intercol = bpy.data.collections.get('interpolation_tool') + vl_col = bpy.context.view_layer.layer_collection.children.get(intercol.name) + intercol.hide_viewport = vl_col.exclude = vl_col.hide_viewport = False + + # Override collection + col = intercol + ## TODO: Hide all other collections + + dg = bpy.context.evaluated_depsgraph_get() + strokes_data = [] + + for stroke in tgt_strokes: + nb_points = len(stroke.points) + + local_co = np.empty(nb_points * 3, dtype='float64') + stroke.points.foreach_get('co', local_co) + # local_co_3d = local_co.reshape((nb_points, 3)) + world_co_3d = matrix_transform(local_co.reshape((nb_points, 3)), matrix) + + stroke_data = [] + for i, point in enumerate(stroke.points): + point_co_world = world_co_3d[i] + + object_hit, hit_location, tri, tri_indices = ray_cast_point(point_co_world, origin, dg) + if not object_hit or object_hit not in col.all_objects[:]: + for square_co in search_square(point_co_world, factor=settings.search_range): + object_hit, hit_location, tri, tri_indices = ray_cast_point(square_co, origin, dg) + if object_hit and object_hit in col.all_objects[:]: + + hit_location = intersect_line_plane(origin, point_co_world, tri[0], triangle_normal(*tri)) + + break + + stroke_data.append((stroke, point_co_world, object_hit, hit_location, tri, tri_indices)) + + strokes_data.append(stroke_data) + + # Copy stroke selection, jump frame and paste + + bpy.ops.gpencil.copy() + scn.frame_set(frame_to_jump) + plan_co, plane_no = get_gp_draw_plane(gp) + bpy.ops.gpencil.paste() + + if settings.method == 'BONE': + bone_plane = plane_on_bone(settings.target_rig.pose.bones.get(settings.target_bone), + arm=settings.target_rig, + set_rotation=settings.use_bone_rotation, + mesh=True) + + dg = bpy.context.evaluated_depsgraph_get() + matrix_inv = np.array(gp.matrix_world.inverted(), dtype='float64')#.inverted() + new_strokes = gp.data.layers.active.active_frame.strokes[-len(strokes_data):] + + for new_stroke, stroke_data in zip(new_strokes, strokes_data): + world_co_3d = [] # np.array(len()dtype='float64')#np. + for stroke, point_co, object_hit, hit_location, tri_a, tri_indices in stroke_data: + eval_ob = object_hit.evaluated_get(dg) + tri_b = [eval_ob.data.vertices[i].co for i in tri_indices] + tri_b = matrix_transform(tri_b, eval_ob.matrix_world) + + new_loc = barycentric_transform(hit_location, *tri_a, *tri_b) + world_co_3d.append(new_loc) + + # Reproject on plane + new_world_co_3d = [intersect_line_plane(origin, p, plan_co, plane_no) for p in world_co_3d] + new_local_co_3d = matrix_transform(new_world_co_3d, matrix_inv) + + nb_points = len(new_stroke.points) + new_stroke.points.foreach_set('co', new_local_co_3d.reshape(nb_points*3)) + new_stroke.points.update() + + ## Reset autokey status + # context.tool_settings.use_keyframe_insert_auto = auto_key_status # (Done in context manager) + + ## TODO: Remove plane on the fly + + return {'FINISHED'} + +""" class GP_OT_interpolate_stroke(bpy.types.Operator): bl_idname = "gp.interpolate_stroke" bl_label = "Interpolate Stroke" @@ -83,14 +284,11 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): context.tool_settings.use_keyframe_insert_auto = True ## Determine on what key to jump - # frame_to_jump = following_key(forward=self.next) frame_to_jump = following_key(forward=self.next) if frame_to_jump is None: self.report({'WARNING'}, 'No keyframe available in this direction') return {'CANCELLED'} - # print('frame_to_jump: ', frame_to_jump) - gp = context.object scn = bpy.context.scene @@ -146,44 +344,6 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): strokes_data.append(stroke_data) - bpy.ops.gpencil.copy() - - scn.frame_set(frame_to_jump) - - dg = bpy.context.evaluated_depsgraph_get() - - plan_co, plane_no = get_gp_draw_plane(gp) - - bpy.ops.gpencil.paste() - - - matrix_inv = np.array(gp.matrix_world.inverted(), dtype='float64')#.inverted() - new_strokes = gp.data.layers.active.active_frame.strokes[-len(strokes_data):] - - bone_plane = plane_on_bone(settings.target_rig.pose.bones.get(settings.target_bone), - arm=settings.target_rig, - set_rotation=settings.use_bone_rotation) - - for new_stroke, stroke_data in zip(new_strokes, strokes_data): - world_co_3d = [] # np.array(len()dtype='float64')#np. - for stroke, point_co, hit_location, tri_a, tri_indices in stroke_data: - tri_b = [bone_plane[i] for i in tri_indices] - # tri_b = matrix_transform(tri_b, settings.target_rig.matrix_world) - ## rotate tri_b by bone differential angle camera's aim axis ? - - new_loc = barycentric_transform(hit_location, *tri_a, *tri_b) - world_co_3d.append(new_loc) - - - # Reproject on plane - new_world_co_3d = [intersect_line_plane(origin, p, plan_co, plane_no) for p in world_co_3d] - new_local_co_3d = matrix_transform(new_world_co_3d, matrix_inv) - - nb_points = len(new_stroke.points) - new_stroke.points.foreach_set('co', new_local_co_3d.reshape(nb_points*3)) - new_stroke.points.update() - - else: ## Geometry method for stroke in tgt_strokes: @@ -212,18 +372,48 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): strokes_data.append(stroke_data) + # Copy stroke selection, jump frame and paste - bpy.ops.gpencil.copy() + bpy.ops.gpencil.copy() - scn.frame_set(frame_to_jump) + scn.frame_set(frame_to_jump) + plan_co, plane_no = get_gp_draw_plane(gp) + + bpy.ops.gpencil.paste() + + + + if settings.method == 'BONE': + matrix_inv = np.array(gp.matrix_world.inverted(), dtype='float64')#.inverted() + new_strokes = gp.data.layers.active.active_frame.strokes[-len(strokes_data):] + + bone_plane = plane_on_bone(settings.target_rig.pose.bones.get(settings.target_bone), + arm=settings.target_rig, + set_rotation=settings.use_bone_rotation) + + for new_stroke, stroke_data in zip(new_strokes, strokes_data): + world_co_3d = [] # np.array(len()dtype='float64')#np. + for stroke, point_co, hit_location, tri_a, tri_indices in stroke_data: + tri_b = [bone_plane[i] for i in tri_indices] + # tri_b = matrix_transform(tri_b, settings.target_rig.matrix_world) + ## rotate tri_b by bone differential angle camera's aim axis ? + + new_loc = barycentric_transform(hit_location, *tri_a, *tri_b) + world_co_3d.append(new_loc) + + + # Reproject on plane + new_world_co_3d = [intersect_line_plane(origin, p, plan_co, plane_no) for p in world_co_3d] + new_local_co_3d = matrix_transform(new_world_co_3d, matrix_inv) + + nb_points = len(new_stroke.points) + new_stroke.points.foreach_set('co', new_local_co_3d.reshape(nb_points*3)) + new_stroke.points.update() + + else: dg = bpy.context.evaluated_depsgraph_get() - - plan_co, plane_no = get_gp_draw_plane(gp) - bpy.ops.gpencil.paste() - - matrix_inv = np.array(gp.matrix_world.inverted(), dtype='float64')#.inverted() new_strokes = gp.data.layers.active.active_frame.strokes[-len(strokes_data):] @@ -245,10 +435,11 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): new_stroke.points.foreach_set('co', new_local_co_3d.reshape(nb_points*3)) new_stroke.points.update() + ## Reset autokey status context.tool_settings.use_keyframe_insert_auto = auto_key_status return {'FINISHED'} - +""" classes = ( GP_OT_interpolate_stroke, diff --git a/utils.py b/utils.py index d458c7e..c4d8d40 100644 --- a/utils.py +++ b/utils.py @@ -9,6 +9,30 @@ from bpy_extras.object_utils import world_to_camera_view from mathutils.geometry import (barycentric_transform, intersect_point_tri, intersect_point_line, intersect_line_plane, tessellate_polygon) +## context manager to store restore + +class attr_set(): + '''Receive a list of tuple [(data_path, "attribute" [, wanted value)] ] + entering with-statement : Store existing values, assign wanted value (if any) + exiting with-statement: Restore values to their old values + ''' + + def __init__(self, attrib_list): + self.store = [] + # item = (prop, attr, [new_val]) + for item in attrib_list: + prop, attr = item[:2] + self.store.append( (prop, attr, getattr(prop, attr)) ) + if len(item) >= 3: + setattr(prop, attr, item[2]) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_traceback): + for prop, attr, old_val in self.store: + setattr(prop, attr, old_val) + # --- Vector def triangle_normal(a, b, c): @@ -67,17 +91,23 @@ def ray_cast_point(point, origin, depsgraph): return object_hit, np.array(hit_location), tri, tri_indices -def plane_on_bone(bone, arm=None, cam=None, set_rotation=True): +def plane_on_bone(bone, arm=None, cam=None, set_rotation=True, mesh=True): + ''' + bone (posebone): reference pose bone + arm (optional: Armature): Armature of the pose bone (if not passed found using bone.id_data) + cam (optional: Camera) : Camera to align plane to (if not passed use scene camera) + set_rotation (bool): rotate the plane on cam view axis according to bone direction in 2d cam space + mesh (bool): create a real mesh ans return it, else return list of plane coordinate + ''' + if cam is None: cam = bpy.context.scene.camera if arm is None: arm = bone.id_data - plane = plane_coords() mat = cam.matrix_world.copy() - if set_rotation: head_world_coord = arm.matrix_world @ bone.head mat.translation = head_world_coord @@ -85,7 +115,7 @@ def plane_on_bone(bone, arm=None, cam=None, set_rotation=True): ## Apply 2d bone rotation facing camera # Get 2d camera space coords (NDC: normalized device coordinate, 0,0 is bottom-left) - head_2d, tail_2d = utils.get_bone_head_tail_2d(bone, cam=cam) + head_2d, tail_2d = get_bone_head_tail_2d(bone, cam=cam) vec_from_corner_2d = (tail_2d - head_2d).normalized() up_vec_2d = Vector((0,1)) # angle = acos(up_vec_2d.dot(vec_from_corner_2d)) ## equivalent but not signed! @@ -98,15 +128,52 @@ def plane_on_bone(bone, arm=None, cam=None, set_rotation=True): ## Axis camera origin -> pivot rot_axis = head_world_coord - cam.matrix_world.translation - mat = utils.rotate_matrix_around_pivot(mat, angle, head_world_coord, rot_axis) + mat = rotate_matrix_around_pivot(mat, angle, head_world_coord, rot_axis) else: ## Use mid bone to better follow movement mat.translation = arm.matrix_world @ ((bone.tail + bone.head) / 2) # Mid bone - mat_scale = Matrix.Scale(10, 4) + if mesh: + # get/create collection + col = bpy.data.collections.get('interpolation_tool') + if not col: + col = bpy.data.collections.new('interpolation_tool') + + if col.name not in bpy.context.scene.collection.children: + bpy.context.scene.collection.children.link(col) + + # get/create meshplane + plane = bpy.data.objects.get('interpolation_plane') + if not plane: + plane = create_plane(name='interpolation_plane') + + if plane.name not in col.objects: + col.objects.link(plane) + + plane.matrix_world = mat + return plane + + plane = plane_coords() + mat_scale = Matrix.Scale(10, 4) # maybe move above mesh condition return matrix_transform(plane, mat @ mat_scale) +def create_plane(name='Plane', collection=None): + '''Create a plane using pydata + collection: link in passed collection, else do not link in scene + ''' + x = 1.0 + y = 1.0 + vert = [(-x, -y, 0.0), (x, -y, 0.0), (-x, y, 0.0), (x, y, 0.0)] + fac = [(0, 1, 3, 2)] + pl_data = bpy.data.meshes.new(name) + pl_data.from_pydata(vert, [], fac) + pl_obj = bpy.data.objects.new(name, pl_data) + # collection = bpy.context.collection + if collection: + collection.objects.link(pl_obj) + return pl_obj + def intersect_with_tesselated_plane(point, origin, face_co): ''' face_co: World face coordinates