diff --git a/__init__.py b/__init__.py index b70ab80..e0ecada 100755 --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "GP Interpolate", "author": "Christophe Seux, Samuel Bernou", - "version": (0, 8, 5), + "version": (0, 9, 0), "blender": (4, 0, 2), "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 fe0ae78..72b56ff 100644 --- a/interpolate_strokes/operators.py +++ b/interpolate_strokes/operators.py @@ -35,10 +35,14 @@ class GP_OT_interpolate_stroke_base(bpy.types.Operator): @classmethod def description(cls, context, properties): + if properties.interactive: + return "Interactive interpolate mode\ + \nUse Left <- -> Right keys\ + \n+Ctrl to jump over key interpolated during modal" if properties.next: - return f"Interpolate Stroke Forward" + return "Interpolate Stroke Forward" else: - return f"Interpolate Stroke Backward" + return "Interpolate Stroke Backward" def apply_and_store(self, attrs): '''individual item in attrs: (prop, attr, [new_val])''' @@ -100,7 +104,7 @@ class GP_OT_interpolate_stroke_base(bpy.types.Operator): return self.exit(context, status='ERROR', text='No stroke selected!') - ## For now, operators have their own invoke + ## Added to operators owns invoke with uper().invoke(context, event) def invoke(self, context, event): self.debug = False self.stored_attrs = [] # context manager store/restore @@ -111,7 +115,7 @@ class GP_OT_interpolate_stroke_base(bpy.types.Operator): self.tool_col = None # collection containing 3D plane self.gp = context.object self.settings = context.scene.gp_interpo_settings - self.frames_to_jump = None + self.frames_to_jump = [] self.cancelled = False self.timer = None self.timer_event = 'TIMER' @@ -137,17 +141,14 @@ class GP_OT_interpolate_stroke_base(bpy.types.Operator): ## Set active layer self.gp.data.layers.active = layers[0] - - # if self.interactive: - # ## TODO: Allow even if 0 keys are available - # ## disable timer event detection - # self.timer_event = None - # # Add available keys in other direction then sort - # self.frames_to_jump += following_keys(forward=not self.next, animation=self.settings.use_animation or self.interactive) - # self.frames_to_jump.sort() - - if not self.interactive: + if self.interactive: + self.frames_to_jump = following_keys(forward=True, animation=True) + self.frames_to_jump += following_keys(forward=False, animation=True) + self.frames_to_jump.append(context.scene.frame_current) + self.frames_to_jump.sort() + context.area.header_text_set('Frame interpolation < jump with left-right arrow keys > | Esc/Enter: Stop') # (+Ctrl to skip all already interpolated) + else: ## Determine on what key/keys to jump self.frames_to_jump = following_keys(forward=self.next, animation=self.settings.use_animation or self.interactive) if not len(self.frames_to_jump): @@ -156,6 +157,69 @@ class GP_OT_interpolate_stroke_base(bpy.types.Operator): # TODO: Expose timer (in preferences ?) to let user more time to see result between frames self.timer = context.window_manager.event_timer_add(0.04, window=context.window) + if self.report_progress: + context.window_manager.progress_begin(self.frames_to_jump[0], self.frames_to_jump[-1]) # Pgs + + + def modal(self, context, event): + scn = context.scene + + if event.type in {'RIGHTMOUSE', 'ESC', 'RET'}: + return self.exit(context, status='WARNING', text='Cancelling', cancelled=True) + + if self.interactive: + frame = None + current_frame = context.scene.frame_current + self.loop_count = 0 # Reset to keep inifinite loop + if event.type == 'LEFT_ARROW' and event.value == 'PRESS': + if event.ctrl: + frame = next((f for f in self.frames_to_jump[::-1] if f < current_frame and f not in self.interpolated_keys), None) + else: + frame = next((f for f in self.frames_to_jump[::-1] if f < current_frame), None) + if event.type == 'RIGHT_ARROW' and event.value == 'PRESS': + if event.ctrl: + frame = next((f for f in self.frames_to_jump if f > current_frame and f not in self.interpolated_keys), None) + else: + frame = next((f for f in self.frames_to_jump if f > current_frame), None) + + if (event.type in ('LEFT_ARROW', 'RIGHT_ARROW') and event.value == 'PRESS') and frame is None: + self.report({'WARNING'}, 'No frame to jump to in this direction!') + + else: + 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') + + ## -- Enter if LOOPTIMER or INTERACTIVE left-right shortcut + if event.type == self.timer_event or (self.interactive and frame is not None): + if not self.interactive: + frame = self.frames_to_jump[self.loop_count] + scn.frame_set(frame) + if frame in self.interpolated_keys: + self.report({'INFO'}, f'SKIP {frame} (already interpolated)') + return {'RUNNING_MODAL'} + print(f'-> {frame}') + if self.report_progress: + context.window_manager.progress_update(frame) # Pgs + + ## Interpolate function + self.interpolate_frame(context) + + if self.interactive: + self.interpolated_keys.add(frame) + else: + self.loop_count += 1 + if self.loop_count >= len(self.frames_to_jump): + return self.exit(context) + + # bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + + return {'RUNNING_MODAL'} + + def interpolate_frame(self, context): + raise Exception('Not Implemented') + + ## Converted to modal from "operator_single" class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base): @@ -321,122 +385,68 @@ class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base): bpy.ops.gpencil.select_linked() # Ensure whole stroke are selected before copy bpy.ops.gpencil.copy() - # Jump frame and paste - - if self.report_progress: - 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): + def interpolate_frame(self, context): scn = context.scene + origin = scn.camera.matrix_world.to_translation() + plane_co, plane_no = get_gp_draw_plane(self.gp) + bpy.ops.gpencil.select_all(action='DESELECT') + bpy.ops.gpencil.paste() - if self.interactive: - frame = None - self.loop_count = 0 # Reset to keep inifinite loop - prev_frame_num = following_keys(forward=False) - prev_frame_num = prev_frame_num[0] if prev_frame_num else None - next_frame_num = following_keys(forward=True) - next_frame_num = next_frame_num[0] if next_frame_num else None - context.area.header_text_set(f'Interpolate: {prev_frame_num} < {context.scene.frame_current} > {next_frame_num} | Esc: Finish') + if self.settings.method == '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) - if event.type == 'LEFT_ARROW' and event.value == 'PRESS': - frame = prev_frame_num - if event.type == 'RIGHT_ARROW' and event.value == 'PRESS': - frame = next_frame_num - if frame is None: - self.report({'WARNING'}, 'No frame to jump to in this direction!') + dg = bpy.context.evaluated_depsgraph_get() + + ## Get pasted stroke + new_strokes = [s for s in self.gp.data.layers.active.active_frame.strokes if s.select] + ## Keep reference to all accessible other strokes (in all accessible layer) + other_strokes = [s for l in self.gp.data.layers if l.active_frame and not l.lock for s in l.active_frame.strokes if not s.select] + + occluded_points = [] + 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] - else: - 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') + new_loc = barycentric_transform(hit_location, *tri_a, *tri_b) + world_co_3d.append(new_loc) - if event.type in {'RIGHTMOUSE', 'ESC', 'RET'}: - return self.exit(context, status='WARNING', text='Cancelling', cancelled=True) + ## 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() - ## -- LOOPTIMER - if event.type == self.timer_event or (self.interactive and frame is not None): - if not self.interactive: - frame = self.frames_to_jump[self.loop_count] - scn.frame_set(frame) - if frame in self.interpolated_keys: - self.report({'INFO'}, f'SKIP {frame} (already interpolated)') - return {'RUNNING_MODAL'} - print(f'-> {frame}') - if self.report_progress: - context.window_manager.progress_update(frame) # Pgs + ## Occlusion management + if self.settings.method == 'GEOMETRY' and self.settings.remove_occluded: + for i, point in enumerate(new_stroke.points): + point_co = world_co_3d[i] + vec_direction = point_co - origin + ## Raycast with slightly reduced distance (avoid occlusion on initial surface) + n_hit, _, _, _, _, _ = scn.ray_cast(dg, origin, vec_direction, distance=vec_direction.length - 0.001) + if n_hit: + occluded_points.append(point) - origin = scn.camera.matrix_world.to_translation() - plane_co, plane_no = get_gp_draw_plane(self.gp) + if occluded_points: + ## Select only occluded point bpy.ops.gpencil.select_all(action='DESELECT') - bpy.ops.gpencil.paste() + for point in occluded_points: + point.select = True + ## remove points + bpy.ops.gpencil.delete(type='POINTS') - if self.settings.method == '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) - - dg = bpy.context.evaluated_depsgraph_get() - - ## Get pasted stroke - new_strokes = [s for s in self.gp.data.layers.active.active_frame.strokes if s.select] - ## Keep reference to all accessible other strokes (in all accessible layer) - other_strokes = [s for l in self.gp.data.layers if l.active_frame and not l.lock for s in l.active_frame.strokes if not s.select] - - occluded_points = [] - 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] - - 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 = [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() - - ## Occlusion management - if self.settings.method == 'GEOMETRY' and self.settings.remove_occluded: - for i, point in enumerate(new_stroke.points): - point_co = world_co_3d[i] - vec_direction = point_co - origin - ## Raycast with slightly reduced distance (avoid occlusion on initial surface) - n_hit, _, _, _, _, _ = scn.ray_cast(dg, origin, vec_direction, distance=vec_direction.length - 0.001) - if n_hit: - occluded_points.append(point) - - if occluded_points: - ## Select only occluded point - bpy.ops.gpencil.select_all(action='DESELECT') - for point in occluded_points: - point.select = True - ## remove points - bpy.ops.gpencil.delete(type='POINTS') - - ## restore selection (keep new strokes selected) - bpy.ops.gpencil.select_all(action='SELECT') - for stroke in other_strokes: - stroke.select = False - - if self.interactive: - self.interpolated_keys.add(frame) - else: - self.loop_count += 1 - if self.loop_count >= len(self.frames_to_jump): - return self.exit(context) - - # bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) - - return {'RUNNING_MODAL'} + ## restore selection (keep new strokes selected) + bpy.ops.gpencil.select_all(action='SELECT') + for stroke in other_strokes: + stroke.select = False classes = ( diff --git a/interpolate_strokes/operators_triangle.py b/interpolate_strokes/operators_triangle.py index 6a05b66..a8fea88 100644 --- a/interpolate_strokes/operators_triangle.py +++ b/interpolate_strokes/operators_triangle.py @@ -32,14 +32,12 @@ class GP_OT_interpolate_stroke_tri(GP_OT_interpolate_stroke_base): return tgt_strokes ## Prepare context manager - self.store_list = [ + attrs = [ # (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() + self.apply_and_store(attrs) point_dict = context.window_manager.get(f'tri_{self.gp.name}') ## point_dict -> {'0': {'object': object_name_as_str, 'index': 450}, ...} @@ -75,69 +73,48 @@ class GP_OT_interpolate_stroke_tri(GP_OT_interpolate_stroke_base): if self.debug: self.scan_time = time()-self.start print(f'Scan time {self.scan_time:.4f}s') - + # Ensure whole stroke are selected before copy bpy.ops.gpencil.select_linked() # Copy stroke selection 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 + # if self.report_progress: + # context.window_manager.progress_begin(self.frames_to_jump[0], self.frames_to_jump[-1]) # Pgs + # context.area.header_text_set('Starting interpolation | Esc: Cancel') 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') + def interpolate_frame(self, context): + scn = context.scene + origin = scn.camera.matrix_world.to_translation() + plane_co, plane_no = get_gp_draw_plane(self.gp) + bpy.ops.gpencil.paste() - if event.type in {'RIGHTMOUSE', 'ESC'}: - context.area.header_text_set(f'Cancelling') - return self.exit(context, status='WARNING', text='Cancelling', cancelled=True) + dg = bpy.context.evaluated_depsgraph_get() + + ## List of newly pasted strokes (using range) + new_strokes = self.gp.data.layers.active.active_frame.strokes[-len(self.strokes_data):] - ## -- 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() + ## 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) - dg = bpy.context.evaluated_depsgraph_get() - - ## List of newly pasted strokes (using range) - new_strokes = self.gp.data.layers.active.active_frame.strokes[-len(self.strokes_data):] + for new_stroke, stroke_data in zip(list(new_strokes), list(self.strokes_data)): + world_co_3d = [] + for hit_location, tri_a in stroke_data: + new_loc = barycentric_transform(hit_location, *tri_a, *tri_b) + world_co_3d.append(new_loc) - ## 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) - - for new_stroke, stroke_data in zip(list(new_strokes), list(self.strokes_data)): - world_co_3d = [] - 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 = [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() - - ## Setup next loop and redraw - self.loop_count += 1 - if self.loop_count >= len(self.frames_to_jump): - return self.exit(context) - - bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) - - return {'RUNNING_MODAL'} + ## 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() classes = ( GP_OT_interpolate_stroke_tri,