import bpy import numpy as np from time import perf_counter, time, sleep from mathutils import Vector, Matrix from gp_interpolate.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) ## Converted to modal from "operator_single" 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 apply_and_store(self): # self.store = [] # item = (prop, attr, [new_val]) for item in self.store_list: prop, attr = item[:2] self.store.append( (prop, attr, getattr(prop, attr)) ) if len(item) >= 3: setattr(prop, attr, item[2]) def restore(self): for prop, attr, old_val in self.store: setattr(prop, attr, old_val) def invoke(self, context, event): self.debug = False self.status = 'START' self.store_list = [] self.store = [] self.loop_count = 0 self.start = time() self.scan_time = None self.plane = None self.toolcol = None self.gp = context.object self.settings = context.scene.gp_interpo_settings self.frames_to_jump = None self.cancelled = False 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): 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) 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': 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'} # print('self.frames_to_jump: ', self.frames_to_jump) 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 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'} 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 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() 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 si, stroke in enumerate(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] 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) ## 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): 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) if object_hit: ## Get location coplanar with triangle hit_location = intersect_line_plane(origin, point_co_world, tri[0], triangle_normal(*tri)) 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') # 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 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):] # 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: 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() return {'RUNNING_MODAL'} classes = ( GP_OT_interpolate_stroke, ) def register(): for c in classes: bpy.utils.register_class(c) def unregister(): for c in reversed(classes): bpy.utils.unregister_class(c)