From cb0ea42e19324d4a45da632f523fa9de09347d59 Mon Sep 17 00:00:00 2001 From: pullusb Date: Tue, 23 Jul 2024 17:25:13 +0200 Subject: [PATCH] big code refactor --- __init__.py | 4 +- interpolate_strokes/__init__.py | 8 +- interpolate_strokes/operators.py | 669 ++++++++++------------ interpolate_strokes/operators_single.py | 2 +- interpolate_strokes/operators_triangle.py | 178 ++++++ interpolate_strokes/properties.py | 4 +- ui.py | 8 +- utils.py | 44 +- 8 files changed, 510 insertions(+), 407 deletions(-) create mode 100644 interpolate_strokes/operators_triangle.py diff --git a/__init__.py b/__init__.py index bd11c1b..6de670e 100755 --- a/__init__.py +++ b/__init__.py @@ -1,8 +1,8 @@ bl_info = { "name": "gp interpolate", "author": "Christophe Seux, Samuel Bernou", - "version": (0, 8, 0), - "blender": (3, 6, 0), + "version": (0, 8, 1), + "blender": (4, 0, 2), "location": "Sidebar > Gpencil Tab > Interpolate", "description": "Interpolate Grease pencil strokes over 3D", "warning": "", diff --git a/interpolate_strokes/__init__.py b/interpolate_strokes/__init__.py index 0aadb12..66d65bd 100644 --- a/interpolate_strokes/__init__.py +++ b/interpolate_strokes/__init__.py @@ -1,16 +1,16 @@ -from gp_interpolate.interpolate_strokes import (operators, - properties, +from gp_interpolate.interpolate_strokes import (properties, + operators, + operators_triangle, debug, bind_points, - #interpolate_simple ) modules = ( properties, operators, + operators_triangle, debug, bind_points, - #interpolate_simple, ) if "bpy" in locals(): diff --git a/interpolate_strokes/operators.py b/interpolate_strokes/operators.py index 5beb21b..ce4416f 100644 --- a/interpolate_strokes/operators.py +++ b/interpolate_strokes/operators.py @@ -22,14 +22,15 @@ from mathutils.geometry import (barycentric_transform, intersect_line_plane, tessellate_polygon) -## Converted to modal from "operator_single" -class GP_OT_interpolate_stroke(bpy.types.Operator): - bl_idname = "gp.interpolate_stroke" +class GP_OT_interpolate_stroke_base(bpy.types.Operator): + bl_idname = "gp.interpolate_stroke_base" bl_label = "Interpolate Stroke" - bl_description = 'Interpolate Stroke' + bl_description = 'Interpolate Stroke based on user bound triangle' bl_options = {'REGISTER', 'UNDO'} + next : bpy.props.BoolProperty(name='Next', default=True, options={'SKIP_SAVE'}) + @classmethod def poll(cls, context): if context.active_object and context.object.type == 'GPENCIL'\ @@ -45,8 +46,6 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): else: return f"Interpolate Stroke Backward" - next : bpy.props.BoolProperty(name='Next', default=True, options={'SKIP_SAVE'}) - def apply_and_store(self): # self.store = [] # item = (prop, attr, [new_val]) @@ -60,9 +59,57 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): for prop, attr, old_val in self.store: setattr(prop, attr, old_val) + def exit_modal(self, context, status='INFO', text=None, cancelled=False): + context.area.header_text_set(None) + wm = context.window_manager + wm.progress_end() # Pgs + wm.event_timer_remove(self.timer) + self.restore() + if self.debug: + if self.scan_time is not None: + print(f"Paste'n'place time {time()-self.start - self.scan_time}s") + else: + if self.settings.method == 'BONE': + ## Remove Plane and it's collection after use + if self.plane is not None: + bpy.data.objects.remove(self.plane) + if self.toolcol is not None: + bpy.data.collections.remove(self.toolcol) + + cancel_state = '(Stopped!) ' if cancelled else '' + mess = f'{cancel_state}{self.loop_count} interpolated frame(s) ({time()-self.start:.3f}s)' + + if text: + print(mess) + self.report({status}, text) + else: + self.report({'INFO'}, mess) + + def get_stroke_to_interpolate(self, context): + if not self.gp.data.layers.active: + self.exit_modal(context, status='ERROR', text='No active layer') + return {'CANCELLED'} + if not self.gp.data.layers.active.active_frame: + self.exit_modal(context, status='ERROR', text='No active frame') + return {'CANCELLED'} + + ## Get strokes to interpolate + tgt_strokes = [s for s in self.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 self.gp.data.layers.active.active_frame.strokes: + s.select = True + tgt_strokes = self.gp.data.layers.active.active_frame.strokes + + if not tgt_strokes: + self.exit_modal(context, status='ERROR', text='No stroke selected !') + return {'CANCELLED'} + return tgt_strokes + + ## For now, operators have their own invoke def invoke(self, context, event): self.debug = False - self.status = 'START' self.store_list = [] self.store = [] self.loop_count = 0 @@ -81,402 +128,288 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): if interp_col := bpy.data.collections.get('interpolation_tool'): bpy.data.collections.remove(interp_col) + ## Determine on what key/keys to jump + self.frames_to_jump = following_keys(forward=self.next, animation=self.settings.use_animation) + if not len(self.frames_to_jump): + self.exit_modal(context, status='WARNING', text='No keyframe available in this direction') + return {'CANCELLED'} + + self.timer = context.window_manager.event_timer_add(0.01, window=context.window) + +## Converted to modal from "operator_single" + +class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base): + bl_idname = "gp.interpolate_stroke" + bl_label = "Interpolate Stroke" + bl_description = 'Interpolate Stroke' + bl_options = {'REGISTER', 'UNDO'} + + def iterative_search(self, context, obj, coord, origin, dg): + '''Search geometry for outside point (where raycast did not hit any geometry) + + return : + object_hit, hit_location, tri, tri_indices. + None if nothing found + ''' + + for iteration in range(1, 10): + for square_co in search_square(coord, factor=self.settings.search_range * iteration): + + if obj: + object_hit, hit_location, tri, tri_indices = obj_ray_cast(obj, square_co, origin, dg) + else: + # scene raycast + object_hit, hit_location, tri, tri_indices = ray_cast_point(square_co, origin, dg) + + if object_hit: + ## On location coplanar with face triangle + # hit_location = intersect_line_plane(origin, coord, hit_location, triangle_normal(*tri)) + + ## On view plane + view_vec = context.scene.camera.matrix_world.to_quaternion() @ Vector((0,0,1)) + hit_location = intersect_line_plane(origin, coord, hit_location, view_vec) + + ## An average of the two ? + # hit_location_1 = intersect_line_plane(origin, coord, hit_location, triangle_normal(*tri)) + # hit_location_2 = intersect_line_plane(origin, coord, hit_location, view_vec) + # hit_location = (hit_location_1 + hit_location_2) / 2 + + return object_hit, hit_location, tri, tri_indices + + return None, None, None, None + + def invoke(self, context, event): + if state := super().invoke(context, event): + return state + + scn = bpy.context.scene + + origin = scn.camera.matrix_world.to_translation() + + tgt_strokes = self.get_stroke_to_interpolate(context) + if isinstance(tgt_strokes, set): + return tgt_strokes + + col = self.settings.target_collection + if not col: + col = scn.collection + + included_cols = [c.name for c in self.gp.users_collection] + target_obj = None + + ## Setup depending on method + if self.settings.method == 'BONE': + if not self.settings.target_rig or not self.settings.target_bone: + self.exit_modal(context, status='ERROR', text='No Bone selected') + return {'CANCELLED'} + + included_cols.append('interpolation_tool') + + ## Ensure collection and plane exists + # get/create collection + self.toolcol = bpy.data.collections.get('interpolation_tool') + if not self.toolcol: + self.toolcol = bpy.data.collections.new('interpolation_tool') + + if self.toolcol.name not in bpy.context.scene.collection.children: + bpy.context.scene.collection.children.link(self.toolcol) + self.toolcol.hide_viewport = True # needed ? + + # get/create meshplane + self.plane = bpy.data.objects.get('interpolation_plane') + if not self.plane: + self.plane = create_plane(name='interpolation_plane') + self.plane.select_set(False) + + if self.plane.name not in self.toolcol.objects: + self.toolcol.objects.link(self.plane) + target_obj = self.plane + + elif self.settings.method == 'GEOMETRY': + if col != context.scene.collection: + included_cols.append(col.name) + ## Maybe include a plane just behind geo ? probably bad idea + + elif self.settings.method == 'OBJECT': + if not self.settings.target_object: + self.exit_modal(context, status='ERROR', text='No Object selected') + return {'CANCELLED'} + col = scn.collection # Reset collection filter + target_obj = self.settings.target_object + if target_obj.library: + ## Look if an override exists in scene to use instead of default object + if (override := next((o for o in scn.objects if o.name == target_obj.name and o.override_library), None)): + target_obj = override + + ## Prepare context manager + self.store_list = [ + # (context.view_layer.objects, 'active', self.gp), + (context.tool_settings, 'use_keyframe_insert_auto', True), + # (bpy.context.scene.render, 'simplify_subdivision', 0), + ] + + ## Set everything in SETUP list + self.apply_and_store() + + if self.settings.method == 'BONE': + ## replace plane + _bone_plane = plane_on_bone(self.settings.target_rig.pose.bones.get(self.settings.target_bone), + arm=self.settings.target_rig, + set_rotation=self.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 + + dg = bpy.context.evaluated_depsgraph_get() + self.strokes_data = [] + + for stroke_index, stroke in enumerate(tgt_strokes): + stroke_data = [] + for point_index, point in enumerate(stroke.points): + point_co_world = self.gp.matrix_world @ point.co + + if target_obj: + object_hit, hit_location, tri, tri_indices = obj_ray_cast(target_obj, point_co_world, origin, dg) + else: + # scene raycast + object_hit, hit_location, tri, tri_indices = ray_cast_point(point_co_world, origin, dg) + + ## Iterative increasing search range when no surface hit + if not object_hit: + object_hit, hit_location, tri, tri_indices = self.iterative_search(context, target_obj, point_co_world, origin, dg) + + if not object_hit: + ## /!\ ERROR ! No surface found! + # For debugging, select only point. + bpy.ops.gpencil.select_all(action='DESELECT') + point.select = True + self.exit_modal(context, status='ERROR', text=f'Stroke {stroke_index} point {point_index} could not find underlying geometry') + return {'CANCELLED'} + + stroke_data.append((stroke, point_co_world, object_hit, hit_location, tri, tri_indices)) + + self.strokes_data.append(stroke_data) + + if self.debug: + self.scan_time = time()-self.start + print(f'Scan time {self.scan_time:.4f}s') + + # Copy stroke selection + bpy.ops.gpencil.select_linked() # Ensure whole stroke are selected before copy + bpy.ops.gpencil.copy() + + # Jump frame and paste + + context.window_manager.progress_begin(self.frames_to_jump[0], self.frames_to_jump[-1]) # Pgs context.window_manager.modal_handler_add(self) - self._timer = context.window_manager.event_timer_add(0.01, window=context.window) context.area.header_text_set('Starting interpolation | Esc: Cancel') + return {'RUNNING_MODAL'} - def exit_modal(self, context, status='INFO', text=None): - context.area.header_text_set(None) - wm = context.window_manager - wm.progress_end() # Pgs - wm.event_timer_remove(self._timer) - self.restore() - if self.debug: - ## show as solid ? - # if self.plane is not None: - # self.plane.display_type = 'SOLID' - if self.scan_time is not None: - print(f"Paste'n'place time {time()-self.start - self.scan_time}s") - else: - if self.settings.method == 'BONE': - ## Remove Plane and it's collection after use - if self.plane is not None: - bpy.data.objects.remove(self.plane) - if self.toolcol is not None: - bpy.data.collections.remove(self.toolcol) - - cancel_state = '(Stopped!) ' if self.cancelled else '' - mess = f'{cancel_state}{self.loop_count} interpolated frame(s) ({time()-self.start:.3f}s)' - if text: - print(mess) - self.report({status}, text) - else: - ## report standard info - if self.loop_count > 1: - self.report({'INFO'}, mess) - def modal(self, context, event): + frame_num = len(self.frames_to_jump) + percentage = (self.loop_count) / (frame_num) * 100 + context.area.header_text_set(f'Interpolation {percentage:.0f}% {self.loop_count + 1}/{frame_num} | Esc: Cancel') + # (frame: {self.frames_to_jump[self.loop_count]}) if event.type in {'RIGHTMOUSE', 'ESC'}: - print('Cancelling') - self.status = 'CANCELLED' - self.cancelled = True context.area.header_text_set(f'Cancelling') - self.exit_modal(context) + self.exit_modal(context, text='Cancelling', cancelled=True) return {'CANCELLED'} - if self.frames_to_jump: - frame_num = len(self.frames_to_jump) - percentage = (self.loop_count) / (frame_num) * 100 - context.area.header_text_set(f'Interpolation {percentage:.0f}% {self.loop_count + 1}/{frame_num} | Esc: Cancel') - # (frame: {self.frames_to_jump[self.loop_count]}) - - if self.status == 'START': - if self.settings.method == 'TRI' and not context.window_manager.get(f'tri_{self.gp.name}'): - self.exit_modal(context, status='ERROR', text='Need to bind coordinate first. Use "Bind Tri Point" button') - return {'CANCELLED'} - + ## -- LOOPTIMER + if event.type == 'TIMER': + f = self.frames_to_jump[self.loop_count] + bpy.context.window_manager.progress_update(f) # Pgs scn = bpy.context.scene - ## Determine on what key/keys to jump - self.frames_to_jump = following_keys(forward=self.next, all_keys=self.settings.use_animation) - if not len(self.frames_to_jump): - self.exit_modal(context, status='WARNING', text='No keyframe available in this direction') - return {'CANCELLED'} - self.gp = context.object - - # matrix = np.array(self.gp.matrix_world, dtype='float64') - # origin = np.array(scn.camera.matrix_world.to_translation(), 'float64') - matrix = self.gp.matrix_world + scn.frame_set(f) origin = scn.camera.matrix_world.to_translation() - - col = self.settings.target_collection - if not col: - col = scn.collection - - # print('----') - if not self.gp.data.layers.active: - self.exit_modal(context, status='ERROR', text='No active layer') - return {'CANCELLED'} - if not self.gp.data.layers.active.active_frame: - self.exit_modal(context, status='ERROR', text='No active frame') - return {'CANCELLED'} - - ## Get strokes to interpolate - tgt_strokes = [s for s in self.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 self.gp.data.layers.active.active_frame.strokes: - s.select = True - tgt_strokes = self.gp.data.layers.active.active_frame.strokes - - if not tgt_strokes: - self.exit_modal(context, status='ERROR', text='No stroke selected !') - return {'CANCELLED'} - - included_cols = [c.name for c in self.gp.users_collection] - target_obj = None - - ## Setup depending on method - if self.settings.method == 'BONE': - if not self.settings.target_rig or not self.settings.target_bone: - self.exit_modal(context, status='ERROR', text='No Bone selected') - return {'CANCELLED'} - - included_cols.append('interpolation_tool') - - ## ensure collection and plane exists - # get/create collection - self.toolcol = bpy.data.collections.get('interpolation_tool') - if not self.toolcol: - self.toolcol = bpy.data.collections.new('interpolation_tool') - - if self.toolcol.name not in bpy.context.scene.collection.children: - bpy.context.scene.collection.children.link(self.toolcol) - self.toolcol.hide_viewport = True # needed ? - - # get/create meshplane - self.plane = bpy.data.objects.get('interpolation_plane') - if not self.plane: - self.plane = create_plane(name='interpolation_plane') - self.plane.select_set(False) - - if self.plane.name not in self.toolcol.objects: - self.toolcol.objects.link(self.plane) - target_obj = self.plane - - elif self.settings.method == 'GEOMETRY': - if col != context.scene.collection: - included_cols.append(col.name) - ## Maybe include a plane just behind geo ? probably bad idea - - elif self.settings.method == 'OBJECT': - if not self.settings.target_object: - self.exit_modal(context, status='ERROR', text='No Object selected') - return {'CANCELLED'} - col = scn.collection # Reset collection filter - target_obj = self.settings.target_object - if target_obj.library: - ## Look if an override exists in scene to use instead of default object - if (override := next((o for o in scn.objects if o.name == target_obj.name and o.override_library), None)): - target_obj = override - - ## Prepare context manager - self.store_list = [ - # (context.view_layer.objects, 'active', self.gp), - (context.tool_settings, 'use_keyframe_insert_auto', True), - # (bpy.context.scene.render, 'simplify_subdivision', 0), - ] - - # TODO: collection filter for GEOMETRY mode optimization - - ## Hide optimizations (safe for Bone Mode only, if no error) - # if self.settings.method == 'BONE': - # ## TEST: Add collections containing rig (cannot be excluded) - # # rig_parent_cols = [c.name for c in scn.collection.children_recursive if self.settings.target_rig.name in c.all_objects] - # # included_cols += rig_parent_cols - # for vlc in context.view_layer.layer_collection.children: - # self.store_list.append( - # # (vlc, 'exclude', vlc.name not in included_cols), # If excluded rig does not update ! - # (vlc, 'hide_viewport', vlc.name not in included_cols), # viewport viz - # ) - - # print(f'Preparation {time()-start:.4f}s') - ## Set everything in SETUP list - self.apply_and_store() + plane_co, plane_no = get_gp_draw_plane(self.gp) + bpy.ops.gpencil.select_all(action='DESELECT') + bpy.ops.gpencil.paste() if self.settings.method == 'BONE': - ## replace plane - _bone_plane = plane_on_bone(self.settings.target_rig.pose.bones.get(self.settings.target_bone), + ## Set plane on the bone + plane_on_bone(self.settings.target_rig.pose.bones.get(self.settings.target_bone), arm=self.settings.target_rig, set_rotation=self.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 - - if self.settings.method == 'TRI': - point_dict = context.window_manager.get(f'tri_{self.gp.name}') - ## point_dict -> {'0': {'object': object_name_as_str, 'index': 450}, ...} - - ## Get triangle dumped in context.window_manager - self.object_hit_source = object_hit = [bpy.context.scene.objects.get(point_dict[str(i)]['object']) for i in range(3)] - self.tri_indices_source = tri_indices = [point_dict[str(i)]['index'] for i in range(3)] # List of vertices index corresponding to tri coordinates - - tri = [] - dg = bpy.context.evaluated_depsgraph_get() - for source_obj, idx in zip(object_hit, tri_indices): - ob_eval = source_obj.evaluated_get(dg) - tri.append(ob_eval.matrix_world @ ob_eval.data.vertices[idx].co) - dg = bpy.context.evaluated_depsgraph_get() - self.strokes_data = [] + # Get only just pasted strokes + new_strokes = [s for s in self.gp.data.layers.active.active_frame.strokes if s.select] + # new_strokes = self.gp.data.layers.active.active_frame.strokes[-len(self.strokes_data):] - for si, stroke in enumerate(tgt_strokes): - nb_points = len(stroke.points) + # For new_stroke, stroke_data in zip(new_strokes, self.strokes_data): + for new_stroke, stroke_data in zip(list(new_strokes), list(self.strokes_data)): + world_co_3d = [] + 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.matrix_world @ eval_ob.data.vertices[i].co for i in tri_indices] - 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) + new_loc = barycentric_transform(hit_location, *tri_a, *tri_b) + world_co_3d.append(new_loc) - stroke_data = [] - for i, point in enumerate(stroke.points): - point_co_world = world_co_3d[i] - - if self.settings.method == 'TRI': - ## Set hit location at same coordinate as point - # hit_location = point_co_world - ## Set hit location on tri plane - hit_location = intersect_line_plane(origin, point_co_world, tri[0], triangle_normal(*tri)) + ## Reproject on plane + new_world_co_3d = [intersect_line_plane(origin, p, plane_co, plane_no) for p in world_co_3d] + new_local_co_3d = [co for coord in new_world_co_3d for co in self.gp.matrix_world.inverted() @ coord] + new_stroke.points.foreach_set('co', new_local_co_3d) + new_stroke.points.update() - # In this case object_hit is a list of object ! - stroke_data.append((stroke, point_co_world, object_hit, hit_location, tri, tri_indices)) + ## Occlusion management + if self.settings.method == 'GEOMETRY' and self.settings.remove_occluded: + viz_list = [True]*len(world_co_3d) + for i, nco in enumerate(world_co_3d): + vec_direction = nco - origin + ## Reduced distance slightly to avoid occlusion on same source... + dist = vec_direction.length - 0.001 + n_hit, _, _, _, _, _ = scn.ray_cast(dg, origin, vec_direction, distance=dist) + # If there is a hit, it's occluded + if n_hit: + viz_list[i] = False + + if all(viz_list): + # All visible, do nothing (just keep previous stroke) continue - if target_obj: - object_hit, hit_location, tri, tri_indices = obj_ray_cast(target_obj, Vector(point_co_world), origin, dg) - else: - # scene raycast - object_hit, hit_location, tri, tri_indices = ray_cast_point(point_co_world, origin, dg) + if any(viz_list): + # Create sub-strokes according to indices in original stroke + for sublist in index_list_from_bools(viz_list): + ## Clear if only one isolated point ? + if len(sublist) == 1: + continue - ## Increasing search range - if not object_hit: # or object_hit not in col.all_objects[:]: - found = False - for iteration in range(1, 6): - for square_co in search_square(point_co_world, factor=self.settings.search_range * iteration): + ns = self.gp.data.layers.active.active_frame.strokes.new() + for elem in ('hardness', 'material_index', 'line_width'): + setattr(ns, elem, getattr(new_stroke, elem)) - if target_obj: - object_hit, hit_location, tri, tri_indices = obj_ray_cast(target_obj, Vector(square_co), origin, dg) - else: - # scene raycast - object_hit, hit_location, tri, tri_indices = ray_cast_point(square_co, origin, dg) + ns.points.add(len(sublist)) + for i, point_index in enumerate(sublist): + for elem in ('uv_factor', 'uv_fill', 'uv_rotation', 'pressure', 'co', 'strength', 'vertex_color'): + setattr(ns.points[i], elem, getattr(new_stroke.points[point_index], elem)) - if object_hit: - ## On location coplanar with face triangle - # hit_location = intersect_line_plane(origin, point_co_world, hit_location, triangle_normal(*tri)) + ## Delete original stroke + self.gp.data.layers.active.active_frame.strokes.remove(new_stroke) - ## On view plane - view_vec = context.scene.camera.matrix_world.to_quaternion() @ Vector((0,0,1)) - hit_location = intersect_line_plane(origin, point_co_world, hit_location, view_vec) - - ## An average of the two ? - # hit_location_1 = intersect_line_plane(origin, point_co_world, hit_location, triangle_normal(*tri)) - # hit_location_2 = intersect_line_plane(origin, point_co_world, hit_location, view_vec) - # hit_location = (hit_location_1 + hit_location_2) / 2 - - found = True - # print(f'{si}:{i} iteration {iteration}') # Dbg - break - - if found: - break - - if not found: - ## /!\ ERROR ! No surface found! - # For debugging, select only problematic stroke (and point) - for sid, s in enumerate(tgt_strokes): - s.select = sid == si - for ip, p in enumerate(stroke.points): - p.select = ip == i - self.exit_modal(context, status='ERROR', text=f'Stroke {si} point {i} could not find underlying geometry') - return {'CANCELLED'} - - stroke_data.append((stroke, point_co_world, object_hit, hit_location, tri, tri_indices)) - - self.strokes_data.append(stroke_data) - - if self.debug: - self.scan_time = time()-self.start - print(f'Scan time {self.scan_time:.4f}s') + self.loop_count += 1 + if self.loop_count >= len(self.frames_to_jump): + self.exit_modal(context) + return {'FINISHED'} - # Copy stroke selection - bpy.ops.gpencil.select_linked() # Ensure whole stroke are selected before copy - bpy.ops.gpencil.copy() - - # Jump frame and paste - bpy.context.window_manager.progress_begin(self.frames_to_jump[0], self.frames_to_jump[-1]) # Pgs - self.status = 'LOOP' - - - ## -- LOOPTIMER - if event.type == 'TIMER': - - if self.status == 'LOOP': - f = self.frames_to_jump[self.loop_count] - bpy.context.window_manager.progress_update(f) # Pgs - scn = bpy.context.scene - scn.frame_set(f) - origin = scn.camera.matrix_world.to_translation() - # origin = np.array(scn.camera.matrix_world.to_translation(), 'float64') - plan_co, plane_no = get_gp_draw_plane(self.gp) - bpy.ops.gpencil.paste() - - if self.settings.method == 'BONE': - bone_plane = plane_on_bone(self.settings.target_rig.pose.bones.get(self.settings.target_bone), - arm=self.settings.target_rig, - set_rotation=self.settings.use_bone_rotation, - mesh=True) - - dg = bpy.context.evaluated_depsgraph_get() - matrix_inv = np.array(self.gp.matrix_world.inverted(), dtype='float64')#.inverted() - new_strokes = self.gp.data.layers.active.active_frame.strokes[-len(self.strokes_data):] - - if self.settings.method == 'TRI': - ## All points have the same new frame triangle (tri_b) - tri_b = [] - for source_obj, idx in zip(self.object_hit_source, self.tri_indices_source): - ob_eval = source_obj.evaluated_get(dg) - tri_b.append(ob_eval.matrix_world @ ob_eval.data.vertices[idx].co) - - # for new_stroke, stroke_data in zip(new_strokes, self.strokes_data): - for new_stroke, stroke_data in zip(reversed(new_strokes), reversed(self.strokes_data)): - world_co_3d = [] - for stroke, point_co, object_hit, hit_location, tri_a, tri_indices in stroke_data: - if not self.settings.method == 'TRI': - 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) - # try: - # new_loc = barycentric_transform(hit_location, *tri_a, *tri_b) - # except Exception as e: - # print(f'\nCould not apply barycentric tranform {eval_ob.name}') - # print(e) - # # bpy.context.scene.cursor.location = hit_location - # self.report({'ERROR'}, f'Stroke {si} point {i} could not find underlying geometry') - # return {'CANCELLED'} - - world_co_3d.append(new_loc) - - ## Test with point in 3D space (Debug) - # nb_points = len(new_stroke.points) - # new_stroke.points.foreach_set('co', np.array(world_co_3d).reshape(nb_points*3)) - # new_stroke.points.update() - - ## 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() - - - ## Occlusion management - if self.settings.method == 'GEOMETRY' and self.settings.remove_occluded: - viz_list = [True]*len(world_co_3d) - for i, nco in enumerate(world_co_3d): - vec_direction = nco - origin - ## Reduced distance slightly to avoid occlusion on same source... - dist = vec_direction.length - 0.001 - n_hit, _hit_location, _normal, _n_face_index, n_object_hit, _matrix = scn.ray_cast(dg, origin, vec_direction, distance=dist) - # if there is a hit, it's occluded - if n_hit: - viz_list[i] = False - - if all(viz_list): - # All visible, do nothing (just keep previous stroke) - continue - - if any(viz_list): - # Create sub-strokes according to indices in original stroke - for sublist in index_list_from_bools(viz_list): - ## Clear if only one isolated point ? - if len(sublist) == 1: - continue - - ns = self.gp.data.layers.active.active_frame.strokes.new() - for elem in ('hardness', 'material_index', 'line_width'): - setattr(ns, elem, getattr(new_stroke, elem)) - - ns.points.add(len(sublist)) - for i, point_index in enumerate(sublist): - for elem in ('uv_factor', 'uv_fill', 'uv_rotation', 'pressure', 'co', 'strength', 'vertex_color'): - setattr(ns.points[i], elem, getattr(new_stroke.points[point_index], elem)) - - ## Delete original stroke - self.gp.data.layers.active.active_frame.strokes.remove(new_stroke) - - self.loop_count += 1 - if self.loop_count >= len(self.frames_to_jump): - self.exit_modal(context) - return {'FINISHED'} - - bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) - # context.area.tag_redraw() + bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + # context.area.tag_redraw() return {'RUNNING_MODAL'} classes = ( + GP_OT_interpolate_stroke_base, GP_OT_interpolate_stroke, ) diff --git a/interpolate_strokes/operators_single.py b/interpolate_strokes/operators_single.py index cda8c84..a9251a1 100644 --- a/interpolate_strokes/operators_single.py +++ b/interpolate_strokes/operators_single.py @@ -59,7 +59,7 @@ class GP_OT_interpolate_stroke(bpy.types.Operator): # context.tool_settings.use_keyframe_insert_auto = True ## Determine on what key/keys to jump - frames_to_jump = following_keys(forward=self.next, all_keys=settings.use_animation) + frames_to_jump = following_keys(forward=self.next, animation=settings.use_animation) if not len(frames_to_jump): self.report({'WARNING'}, 'No keyframe available in this direction') return {'CANCELLED'} diff --git a/interpolate_strokes/operators_triangle.py b/interpolate_strokes/operators_triangle.py new file mode 100644 index 0000000..1b1dbb3 --- /dev/null +++ b/interpolate_strokes/operators_triangle.py @@ -0,0 +1,178 @@ +import bpy +import numpy as np +from time import perf_counter, time, sleep +from mathutils import Vector, Matrix + +from ..utils import (matrix_transform, + plane_on_bone, + ray_cast_point, + obj_ray_cast, + intersect_with_tesselated_plane, + triangle_normal, + search_square, + get_gp_draw_plane, + create_plane, + following_keys, + index_list_from_bools, + attr_set) + +from mathutils.geometry import (barycentric_transform, + intersect_point_tri, + intersect_point_line, + intersect_line_plane, + tessellate_polygon) + + +from .operators import GP_OT_interpolate_stroke_base +## Converted to modal from "operator_single" + +class GP_OT_interpolate_stroke_tri(GP_OT_interpolate_stroke_base): + bl_idname = "gp.interpolate_stroke_tri" + bl_label = "Interpolate Stroke" + bl_description = 'Interpolate Stroke based on user bound triangle' + bl_options = {'REGISTER', 'UNDO'} + + def invoke(self, context, event): + if state := super().invoke(context, event): + return state + + ## START + if not context.window_manager.get(f'tri_{self.gp.name}'): + self.exit_modal(context, status='ERROR', text='Need to bind coordinate first. Use "Bind Tri Point" button') + return {'CANCELLED'} + + scn = bpy.context.scene + + origin = scn.camera.matrix_world.to_translation() + + tgt_strokes = self.get_stroke_to_interpolate(context) + if isinstance(tgt_strokes, set): + return tgt_strokes + + target_obj = None + + ## Prepare context manager + self.store_list = [ + # (context.view_layer.objects, 'active', self.gp), + (context.tool_settings, 'use_keyframe_insert_auto', True), + # (bpy.context.scene.render, 'simplify_subdivision', 0), + ] + + ## Set everything in SETUP list + self.apply_and_store() + + point_dict = context.window_manager.get(f'tri_{self.gp.name}') + ## point_dict -> {'0': {'object': object_name_as_str, 'index': 450}, ...} + + ## Get triangle dumped in context.window_manager + self.source_object_list = [bpy.context.scene.objects.get(point_dict[str(i)]['object']) for i in range(3)] + self.source_tri_indices = [point_dict[str(i)]['index'] for i in range(3)] # List of vertices index corresponding to tri coordinates + + dg = bpy.context.evaluated_depsgraph_get() + + ## Get tri at source frame + tri = [] + for source_obj, idx in zip(self.source_object_list, self.source_tri_indices): + ob_eval = source_obj.evaluated_get(dg) + tri.append(ob_eval.matrix_world @ ob_eval.data.vertices[idx].co) + + self.strokes_data = [] + + for stroke in tgt_strokes: + stroke_data = [] + for point in stroke.points: + point_co_world = self.gp.matrix_world @ point.co + + ## Set hit location at same coordinate as point + # hit_location = point_co_world + + ## Set hit location on tri plane + hit_location = intersect_line_plane(origin, point_co_world, tri[0], triangle_normal(*tri)) + + stroke_data.append((hit_location, tri)) + + self.strokes_data.append(stroke_data) + + if self.debug: + self.scan_time = time()-self.start + print(f'Scan time {self.scan_time:.4f}s') + + # Copy stroke selection + bpy.ops.gpencil.select_linked() # Ensure whole stroke are selected before copy + bpy.ops.gpencil.copy() + + # Jump frame and paste + bpy.context.window_manager.progress_begin(self.frames_to_jump[0], self.frames_to_jump[-1]) # Pgs + + context.window_manager.modal_handler_add(self) + context.area.header_text_set('Starting interpolation | Esc: Cancel') + return {'RUNNING_MODAL'} + + def modal(self, context, event): + frame_num = len(self.frames_to_jump) + percentage = (self.loop_count) / (frame_num) * 100 + context.area.header_text_set(f'Interpolation {percentage:.0f}% {self.loop_count + 1}/{frame_num} | Esc: Cancel') + + if event.type in {'RIGHTMOUSE', 'ESC'}: + context.area.header_text_set(f'Cancelling') + self.exit_modal(context, text='Cancelling', cancelled=True) + return {'CANCELLED'} + + ## -- LOOPTIMER + if event.type == 'TIMER': + f = self.frames_to_jump[self.loop_count] + bpy.context.window_manager.progress_update(f) # Pgs + scn = bpy.context.scene + scn.frame_set(f) + origin = scn.camera.matrix_world.to_translation() + plane_co, plane_no = get_gp_draw_plane(self.gp) + bpy.ops.gpencil.paste() + + dg = bpy.context.evaluated_depsgraph_get() + matrix_inv = np.array(self.gp.matrix_world.inverted(), dtype='float64') + new_strokes = self.gp.data.layers.active.active_frame.strokes[-len(self.strokes_data):] + + ## Get user triangle position at current frame + tri_b = [] + for source_obj, idx in zip(self.source_object_list, self.source_tri_indices): + ob_eval = source_obj.evaluated_get(dg) + tri_b.append(ob_eval.matrix_world @ ob_eval.data.vertices[idx].co) + + ## Apply + for new_stroke, stroke_data in zip(reversed(new_strokes), reversed(self.strokes_data)): + world_co_3d = [] + # for stroke, point_co, object_hit, hit_location, tri_a, indices in stroke_data: + for hit_location, tri_a in stroke_data: + 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, plane_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() + + ## Setup next loop and redraw + self.loop_count += 1 + if self.loop_count >= len(self.frames_to_jump): + self.exit_modal(context) + return {'FINISHED'} + + bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + + return {'RUNNING_MODAL'} + +classes = ( + GP_OT_interpolate_stroke_tri, +) + +def register(): + for c in classes: + bpy.utils.register_class(c) + + +def unregister(): + for c in reversed(classes): + bpy.utils.unregister_class(c) diff --git a/interpolate_strokes/properties.py b/interpolate_strokes/properties.py index 2b313a6..77171cd 100644 --- a/interpolate_strokes/properties.py +++ b/interpolate_strokes/properties.py @@ -44,7 +44,7 @@ class GP_PG_interpolate_settings(PropertyGroup): name='Mode', # Combined ?markers ? items= ( - ('FRAME', 'Frame', 'prev/next scene frame depending on the padding options', 0), + ('FRAME', 'Frame', 'prev/next scene frame depending on the step options', 0), ('GPKEY', 'GP Key', 'prev/next Grease pencil key', 1) , ('RIGKEY', 'Rig Key', 'prev/next armatures keys in targeted collection (camera keys are included)', 2), ), @@ -52,7 +52,7 @@ class GP_PG_interpolate_settings(PropertyGroup): description='Select how the previous or next frame should be chosen' ) - padding : IntProperty(name='Padding', + step : IntProperty(name='Step', description='Number of frame to jump backward or forward', default=2, min=1) diff --git a/ui.py b/ui.py index 095c846..3d87746 100755 --- a/ui.py +++ b/ui.py @@ -31,11 +31,11 @@ class GP_PT_interpolate(bpy.types.Panel): prev_text = f'{scn.frame_preview_start if scn.use_preview_range else scn.frame_start} < {scn.frame_current}' next_text = f'{scn.frame_current} > {scn.frame_preview_end if scn.use_preview_range else scn.frame_end}' - row = col.row(align=True) row.scale_x = 3 - row.operator("gp.interpolate_stroke", text=prev_text, icon=prev_icon).next = False - row.operator("gp.interpolate_stroke", text=next_text, icon=next_icon).next = True + ops_id = "gp.interpolate_stroke_tri" if settings.method == 'TRI' else "gp.interpolate_stroke" + row.operator(ops_id, text=prev_text, icon=prev_icon).next = False + row.operator(ops_id, text=next_text, icon=next_icon).next = True col.prop(settings, 'use_animation', text='Animation') col.prop(settings, 'method', text='Method') @@ -62,7 +62,7 @@ class GP_PT_interpolate(bpy.types.Panel): row.prop(settings, 'mode', expand=True) if settings.mode == 'FRAME': - col.prop(settings, 'padding') + col.prop(settings, 'step', text='Step') if settings.mode == 'RIGKEY': col.prop(settings, 'target_collection', text='Collection') diff --git a/utils.py b/utils.py index 7c5d9f4..aa88723 100644 --- a/utils.py +++ b/utils.py @@ -53,16 +53,9 @@ def triangle_normal(p1, p2, p3): normal.normalize() return normal -## Bad normal calculation !! -# def triangle_normal(a, b, c): -# x = a[1] * b[2] - a[2] * b[1] -# y = a[2] * b[0] - a[0] * b[2] -# z = a[0] * b[1] - a[1] * b[0] -# return np.array([x, y, z], dtype='float64') - def plane_coords(size=1): v = size * 0.5 - return np.array([(-v, v, 0), (v, v, 0), (v, -v, 0), (-v, -v, 0)], dtype='float64') + return [Vector((-v, v, 0)), Vector((v, v, 0)), Vector((v, -v, 0)), Vector((-v, -v, 0))] def matrix_transform(coords, matrix): coords_4d = np.column_stack((coords, np.ones(len(coords), dtype='float64'))) @@ -84,13 +77,14 @@ def search_square(point, factor=0.05, cam=None): depth = vector_magnitude(point - cam.matrix_world.to_translation()) mat_scale = Matrix.Scale(tan(cam.data.angle * 0.5) * depth * factor, 4) - return matrix_transform(plane, mat @ mat_scale) + final_matrix = mat @ mat_scale + return [final_matrix @ co for co in plane] def get_tri_from_face(hit_location, face_index, object_hit, depsgraph): eval_ob = object_hit.evaluated_get(depsgraph) face = eval_ob.data.polygons[face_index] vertices = [eval_ob.data.vertices[i] for i in face.vertices] - face_co = matrix_transform([v.co for v in vertices], eval_ob.matrix_world) + face_co = [eval_ob.matrix_world @ v.co for v in vertices] tri = None for tri_idx in tessellate_polygon([face_co]): @@ -423,7 +417,7 @@ def get_gp_draw_plane(obj=None): ## --- Animation -def following_keys(forward=True, all_keys=False) -> list:# -> list[int] | list | None: +def following_keys(forward=True, animation=False) -> list:# -> list[int] | list | None: '''Return a list of int or an empty list''' direction = 1 if forward else -1 cur_frame = bpy.context.scene.frame_current @@ -437,15 +431,15 @@ def following_keys(forward=True, all_keys=False) -> list:# -> list[int] | list | frames = [] if settings.mode == 'FRAME': - jump = settings.padding * direction - if all_keys: + jump = settings.step * direction + if animation: limit += direction # offset by one for limit to be in range return list(range(cur_frame + jump , limit, jump)) else: return [cur_frame + jump] - elif settings.mode == 'GPKEY': + if settings.mode == 'GPKEY': layers = bpy.context.object.data.layers frames = [f.frame_number for l in layers for f in l.frames] @@ -462,30 +456,28 @@ def following_keys(forward=True, all_keys=False) -> list:# -> list[int] | list | print(obj.name) if not obj.animation_data or not obj.animation_data.action: continue - frames += [k.co.x for fc in obj.animation_data.action.fcurves for k in fc.keyframe_points] - - + frames += [round(k.co.x) for fc in obj.animation_data.action.fcurves for k in fc.keyframe_points] + if not frames: return [] # Sort frames (invert if looking backward) + frames = list(set(frames)) frames.sort(reverse=not forward) - if all_keys: - frames = list(set(frames)) + if animation: if forward: - frame_list = [int(f) for f in frames if f > cur_frame and f <= limit] + frame_list = [f for f in frames if cur_frame < f <= limit] else: - frame_list = [int(f) for f in frames if f < cur_frame and f >= limit] + frame_list = [f for f in frames if limit <= f < cur_frame] return frame_list + ## Single frame if forward: - new = next((f for f in frames if f > cur_frame), None) + frame_list = next(([f] for f in frames if f > cur_frame), []) else: - new = next((f for f in frames if f < cur_frame), None) - if new is None: - return [] - return [int(new)] + frame_list = next(([f] for f in frames if f < cur_frame), []) + return frame_list def index_list_from_bools(bool_list) -> list: