velocity based interpolate, multi layers

master
christophe.seux 2025-01-28 15:44:29 +01:00
parent c16e8a7731
commit d11d1e0435
10 changed files with 426 additions and 39 deletions

4
constants.py Normal file
View File

@ -0,0 +1,4 @@
from pathlib import Path
RESOURCES_DIR = Path(__file__).parent /'resources'

View File

@ -1,6 +1,7 @@
from gp_interpolate.interpolate_strokes import (properties,
operators,
operators_triangle,
operators_velocity,
debug,
bind_points,
)
@ -9,6 +10,7 @@ modules = (
properties,
operators,
operators_triangle,
operators_velocity,
debug,
bind_points,
)

View File

@ -88,20 +88,21 @@ class GP_OT_interpolate_stroke_base(bpy.types.Operator):
return {'FINISHED'}
return {'CANCELLED'}
def get_stroke_to_interpolate(self, context):
## Get strokes to interpolate
tgt_strokes = [s for s in self.gp.data.layers.active.active_frame.strokes if s.select]
# def get_stroke_to_interpolate(self, context):
# ## Get strokes to interpolate
# #tgt_strokes = [s for s in self.gp.data.layers.active.active_frame.strokes if s.select]
# tgt_strokes = [s for l in self.layers for s in l.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 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 tgt_strokes:
return tgt_strokes
# if tgt_strokes:
# return tgt_strokes
return self.exit(context, status='ERROR', text='No stroke selected!')
# return self.exit(context, status='ERROR', text='No stroke selected!')
## Added to operators owns invoke with uper().invoke(context, event)
@ -128,19 +129,28 @@ class GP_OT_interpolate_stroke_base(bpy.types.Operator):
if interp_col := bpy.data.collections.get('interpolation_tool'):
bpy.data.collections.remove(interp_col)
if context.mode != 'EDIT_GPENCIL':
self.report({"ERROR"}, "Mode need to be Edit Grease Pencil")
return {"CANCELLED"}
## Change active layer if strokes are selected only on this layer
layers = [l for l in self.gp.data.layers
if (not l.lock and l.active_frame)
self.layers = [l for l in self.gp.data.layers
if (not l.lock and l.active_frame and not l.hide)
and next((s for s in l.active_frame.strokes if s.select), None)]
if not layers:
return self.exit(context, status='ERROR', text='No stroke selected!')
self.strokes = [s for l in self.layers for s in l.active_frame.strokes if s.select]
if not self.strokes:
self.report({"ERROR"}, "No strokes selected")
return {"CANCELLED"}
elif len(layers) > 1:
return self.exit(context, status='ERROR', text='Strokes selected accross multiple layers!')
#if not self.layers:
# return self.exit(context, status='ERROR', text='No stroke selected!')
#elif len(layers) > 1:
# return self.exit(context, status='ERROR', text='Strokes selected accross multiple layers!')
## Set active layer
self.gp.data.layers.active = layers[0]
#self.gp.data.layers.active = layers[0]
if self.interactive:
self.frames_to_jump = following_keys(forward=True, animation=True)
@ -269,9 +279,10 @@ class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base):
origin = scn.camera.matrix_world.to_translation()
tgt_strokes = self.get_stroke_to_interpolate(context)
if isinstance(tgt_strokes, set):
return tgt_strokes
strokes = [s for l in self.layers for s in l.active_frame.strokes if s.select]
if not strokes:
self.report({"ERROR"}, "No strokes selected")
return {"CANCELLED"}
col = self.settings.target_collection
if not col:
@ -350,7 +361,7 @@ class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base):
dg = bpy.context.evaluated_depsgraph_get()
self.strokes_data = []
for stroke_index, stroke in enumerate(tgt_strokes):
for stroke_index, stroke in enumerate(strokes):
stroke_data = []
for point_index, point in enumerate(stroke.points):
point_co_world = self.gp.matrix_world @ point.co
@ -393,7 +404,7 @@ class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base):
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()
bpy.ops.gpencil.paste(type='LAYER')
if self.settings.method == 'BONE':
## Set plane on the bone
@ -405,10 +416,13 @@ class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base):
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]
#new_strokes = [s for s in self.gp.data.layers.active.active_frame.strokes if s.select]
new_strokes = [s for l in self.layers for s in l.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]
smooth_level = self.settings.smooth_level
occluded_points = []
for new_stroke, stroke_data in zip(list(new_strokes), list(self.strokes_data)):
world_co_3d = []
@ -419,6 +433,22 @@ class GP_OT_interpolate_stroke(GP_OT_interpolate_stroke_base):
new_loc = barycentric_transform(hit_location, *tri_a, *tri_b)
world_co_3d.append(new_loc)
# Smooth points
if smooth_level:
old_co_3d = [s[1] for s in stroke_data]
points_velocity = [b-a for a, b in zip(old_co_3d, world_co_3d)]
# Average of points
for i in range(smooth_level + 1):
points_velocity = [
(points_velocity[i] + points_velocity[i + 1]) / 2 if i == 0 else
(points_velocity[i] + points_velocity[i - 1]) / 2 if i == len(points_velocity) - 1 else
(points_velocity[i - 1] + points_velocity[i] + points_velocity[i + 1]) / 3
for i in range(len(points_velocity))
]
world_co_3d = [a+b for a, b in zip(old_co_3d, points_velocity)]
## 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]

View File

@ -78,7 +78,7 @@ class GP_OT_interpolate_stroke(bpy.types.Operator):
# print('----')
tgt_strokes = [s for s in gp.data.layers.active.active_frame.strokes if s.select]
tgt_strokes = [s for l in self.layers for s in l.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'):
@ -247,7 +247,7 @@ class GP_OT_interpolate_stroke(bpy.types.Operator):
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(gp)
bpy.ops.gpencil.paste()
bpy.ops.gpencil.paste(type="LAYER")
if settings.method == 'BONE':
bone_plane = plane_on_bone(settings.target_rig.pose.bones.get(settings.target_bone),
@ -257,10 +257,10 @@ class GP_OT_interpolate_stroke(bpy.types.Operator):
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):]
new_strokes = [(l, s) for l in self.layers for s in l.active_frame.strokes if s.select]
# for new_stroke, stroke_data in zip(new_strokes, strokes_data):
for new_stroke, stroke_data in zip(reversed(new_strokes), reversed(strokes_data)):
for (layer, new_stroke), stroke_data in zip(reversed(new_strokes), reversed(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)
@ -316,7 +316,7 @@ class GP_OT_interpolate_stroke(bpy.types.Operator):
if len(sublist) == 1:
continue
ns = gp.data.layers.active.active_frame.strokes.new()
ns = layer.active_frame.strokes.new()
for elem in ('hardness', 'material_index', 'line_width'):
setattr(ns, elem, getattr(new_stroke, elem))
@ -326,7 +326,7 @@ class GP_OT_interpolate_stroke(bpy.types.Operator):
setattr(ns.points[i], elem, getattr(new_stroke.points[point_index], elem))
## Delete original stroke
gp.data.layers.active.active_frame.strokes.remove(new_stroke)
layer.active_frame.strokes.remove(new_stroke)
wm.progress_end() # Pgs

View File

@ -27,10 +27,6 @@ class GP_OT_interpolate_stroke_tri(GP_OT_interpolate_stroke_base):
origin = scn.camera.matrix_world.to_translation()
tgt_strokes = self.get_stroke_to_interpolate(context)
if isinstance(tgt_strokes, set):
return tgt_strokes
## Prepare context manager
attrs = [
# (context.view_layer.objects, 'active', self.gp),
@ -55,7 +51,7 @@ class GP_OT_interpolate_stroke_tri(GP_OT_interpolate_stroke_base):
self.strokes_data = []
for stroke in tgt_strokes:
for stroke in self.strokes:
stroke_data = []
for point in stroke.points:
point_co_world = self.gp.matrix_world @ point.co
@ -91,12 +87,13 @@ class GP_OT_interpolate_stroke_tri(GP_OT_interpolate_stroke_base):
scn = context.scene
origin = scn.camera.matrix_world.to_translation()
plane_co, plane_no = get_gp_draw_plane(self.gp)
bpy.ops.gpencil.paste()
bpy.ops.gpencil.paste(type='LAYER')
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):]
new_strokes = [s for l in self.layers for s in l.active_frame.strokes if s.select]
#new_strokes = self.gp.data.layers.active.active_frame.strokes[-len(self.strokes_data):]
## Get user triangle position at current frame
tri_b = []

View File

@ -0,0 +1,275 @@
import bpy
from time import time
import bpy
#from bpy_extras.object_utils import world_to_camera_view
from mathutils import Vector
from mathutils.kdtree import KDTree
from math import tan
from mathutils.geometry import (barycentric_transform,
intersect_line_plane)
from ..utils import (triangle_normal,
get_gp_draw_plane, load_datablock)
from ..constants import RESOURCES_DIR
from .operators import GP_OT_interpolate_stroke_base
def world_to_camera_view(scene, obj, coord):
"""
Returns the camera space coords for a 3d point.
(also known as: normalized device coordinates - NDC).
Where (0, 0) is the bottom left and (1, 1)
is the top right of the camera frame.
values outside 0-1 are also supported.
A negative 'z' value means the point is behind the camera.
Takes shift-x/y, lens angle and sensor size into account
as well as perspective/ortho projections.
:arg scene: Scene to use for frame size.
:type scene: :class:`bpy.types.Scene`
:arg obj: Camera object.
:type obj: :class:`bpy.types.Object`
:arg coord: World space location.
:type coord: :class:`mathutils.Vector`
:return: a vector where X and Y map to the view plane and
Z is the depth on the view axis.
:rtype: :class:`mathutils.Vector`
"""
from mathutils import Vector
co_local = obj.matrix_world.normalized().inverted() @ coord
z = -co_local.z
camera = obj.data
frame = [v for v in camera.view_frame(scene=scene)[:3]]
if camera.type != 'ORTHO':
if z == 0.0:
return Vector((0.5, 0.5, 0.0))
else:
frame = [-(v / (v.z / z)) for v in frame]
min_x, max_x = frame[2].x, frame[1].x
min_y, max_y = frame[1].y, frame[0].y
x = (co_local.x - min_x) / (max_x - min_x)
y = (co_local.y - min_y) / (max_y - min_y)
return Vector((x, y, z))
def camera_view_to_world(scene, obj, coord):
"""Reverse function of world_to_camera_view"""
frame = [obj.matrix_world @ co for co in obj.data.view_frame(scene=scene)]
x, y, z = coord
right_interp = frame[1] + y * (frame[0] - frame[1])
# Interpolate along x-axis (left side)
left_interp = frame[2] + y * (frame[3] - frame[2])
# Interpolate along y-axis
return Vector(left_interp + x * (right_interp - left_interp))
class GP_OT_interpolate_stroke_velocity(GP_OT_interpolate_stroke_base):
bl_idname = "gp.interpolate_stroke_velocity"
bl_label = "Interpolate Stroke"
bl_description = 'Interpolate Stroke based on velocity'
bl_options = {'REGISTER', 'UNDO'}
def invoke(self, context, event):
if state := super().invoke(context, event):
return state
scn = bpy.context.scene
settings = context.scene.gp_interpo_settings
col = settings.target_collection
## Prepare context manager
attrs = [
# (context.view_layer.objects, 'active', self.gp),
(context.tool_settings, 'use_keyframe_insert_auto', True),
# (bpy.context.scene.render, 'simplify_subdivision', 0),
]
self.apply_and_store(attrs)
velocity_mesh = bpy.data.meshes.new('interpolate_velocity')
velocity_ob = bpy.data.objects.new('interpolate_velocity', velocity_mesh)
self.velocity_node_group = load_datablock(RESOURCES_DIR/'nodes.blend', 'Velocity Grid', type='node_groups', link=False)
instance_col_mod = velocity_ob.modifiers.new('IngestCollection', 'NODES')
ingest_node_group = load_datablock(RESOURCES_DIR/'nodes.blend', 'Ingest Collection', type='node_groups', link=False)
instance_col_mod.node_group = ingest_node_group
instance_col_mod["Socket_2"] = col
scn.collection.objects.link(velocity_ob)
# Apply instance collection modifier
dg = bpy.context.evaluated_depsgraph_get()
eval_ob = velocity_ob.evaluated_get(dg)
eval_data = eval_ob.data.copy()
velocity_ob.modifiers.remove(instance_col_mod)
velocity_ob.data = eval_data
bpy.data.node_groups.remove(ingest_node_group)
self.velocity_ob = velocity_ob
if self.debug:
self.scan_time = time()-self.start
print(f'Scan time {self.scan_time:.4f}s')
# Baking Camera
self.camera = scn.camera.copy()
self.camera.data = self.camera.data.copy()
self.camera.animation_data_clear()
self.camera.data.animation_data_clear()
cam_mat = self.camera.matrix_world.copy()
self.camera.animation_data_clear()
self.camera.parent = None
self.camera.matrix_world = cam_mat
# Store curent gp matrix
self.gp_matrix = self.gp.matrix_world.copy()
# Ensure whole stroke are selected before copy
bpy.ops.gpencil.select_linked()
# Copy stroke selection
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.area.header_text_set('Starting interpolation | Esc: Cancel')
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def interpolate_frame(self, context):
scn = context.scene
cam = scn.camera
#dg = bpy.context.evaluated_depsgraph_get()
col = self.settings.target_collection
smooth_level = self.settings.smooth_level
origin = scn.camera.matrix_world.to_translation()
plane_co, plane_no = get_gp_draw_plane(self.gp)
#print("interpolate_frame")
print(self.gp_matrix)
print(self.gp.matrix_world)
velocity_ob = self.velocity_ob
velocity_ob.hide_set(True)
grid_velocity_mod = velocity_ob.modifiers.new('VelocityGrid', 'NODES')
grid_velocity_mod.node_group = self.velocity_node_group
grid_velocity_mod["Socket_2"] = col
grid_velocity_mod["Socket_4"] = self.camera
grid_velocity_mod["Socket_5"] = self.camera.data.angle
grid_velocity_mod["Socket_6"] = self.camera.data.shift_x
grid_velocity_mod["Socket_7"] = self.camera.data.shift_y
#raise Exception()
# Apply velocity grid modifier
dg = bpy.context.evaluated_depsgraph_get()
eval_ob = velocity_ob.evaluated_get(dg)
#eval_data = eval_ob.data.copy()
grid_ob = bpy.data.objects.new('Velocity Grid Object', eval_ob.data.copy())
#copy_ob = velocity_ob.copy()
#scn.collection.objects.link(copy_ob)
velocity_ob.modifiers.remove(grid_velocity_mod)
#velocity_ob.data = eval_data
#Create kd tree for finding nearest points
kd = KDTree(len(grid_ob.data.vertices))
points = [0, 0, 0] * len(grid_ob.data.vertices)
grid_ob.data.vertices.foreach_get('co', points)
for i in range(0, len(points), 3):
kd.insert(points[i:i+3], int(i/3))
kd.balance()
#nb_strokes = len(self.gp.data.layers.active.active_frame.strokes)
bpy.ops.gpencil.paste(type='LAYER')
## List of newly pasted strokes (using range)
new_strokes = [s for l in self.layers for s in l.active_frame.strokes if s.select]
#new_strokes = self.gp.data.layers.active.active_frame.strokes[-nb_strokes:]
velocity_attr = grid_ob.data.attributes["velocity"].data
for stroke in new_strokes:
points = [0, 0, 0] * len(stroke.points)
stroke.points.foreach_get('co', points)
points_2d = [world_to_camera_view(scn, self.camera, self.gp_matrix @ Vector(points[i:i+3])) for i in range(0, len(points), 3)]
points_2d = [Vector((p.x, p.y, 0)) for p in points_2d] # Remove Z component
points_velocity = [velocity_attr[kd.find(p)[1]].vector for p in points_2d]
if smooth_level:
# Average of points
for i in range(smooth_level + 1):
points_velocity = [
(points_velocity[i] + points_velocity[i + 1]) / 2 if i == 0 else
(points_velocity[i] + points_velocity[i - 1]) / 2 if i == len(points_velocity) - 1 else
(points_velocity[i - 1] + points_velocity[i] + points_velocity[i + 1]) / 3
for i in range(len(points_velocity))
]
new_points_3d = [camera_view_to_world(scn, cam, p+vel) for p, vel in zip(points_2d, points_velocity)]
## Reproject on plane
new_points_3d = [intersect_line_plane(origin, p, plane_co, plane_no) for p in new_points_3d]
stroke.points.foreach_set('co', [v for p in new_points_3d for v in self.gp.matrix_world.inverted() @p])
#stroke.points.foreach_set('co', [v for p in points_2d for v in self.gp.matrix_world.inverted() @p])
stroke.points.update()
#velocity_ob.modifiers.remove(grid_velocity_mod)
bpy.data.meshes.remove(grid_ob.data)
def exit(self, context, status='INFO', text=None, cancelled=False):
out = super().exit(context, status='INFO', text=None, cancelled=False)
bpy.data.node_groups.remove(self.velocity_node_group)
bpy.data.meshes.remove(self.velocity_ob.data)
bpy.data.cameras.remove(self.camera.data)
return out
classes = (
GP_OT_interpolate_stroke_velocity,
)
def register():
for c in classes:
bpy.utils.register_class(c)
def unregister():
for c in reversed(classes):
bpy.utils.unregister_class(c)

View File

@ -21,6 +21,7 @@ class GP_PG_interpolate_settings(PropertyGroup):
('OBJECT', 'Object Geometry', 'Same as Geometry mode, but target only a specific object, even if occluded (ignore all the others)', 1),
('BONE', 'Bone', 'Pick an armature bone and follow it', 2),
('TRI', 'Triangle', 'Interpolate based on triangle traced manually over geometry', 3),
('VELOCITY', 'Velocity', 'Interpolate based on velocity, works well for point outside geometry', 4)
),
default='GEOMETRY',
description='Select method for interpolating strokes'
@ -83,6 +84,11 @@ class GP_PG_interpolate_settings(PropertyGroup):
default=True,
description='Apply rotation of the bone') # Bone
#selection: EnumProperty(default='SELECTED', items=[("SELECTED", "Selected", ""), ("ALL", "All", "")],
# description="Stroke to interpolate")
smooth_level: IntProperty(default=2, min=0, max=10, name='Smooth Level')
classes = (
GP_PG_interpolate_settings,
)

BIN
resources/nodes.blend Normal file

Binary file not shown.

15
ui.py
View File

@ -36,7 +36,13 @@ class GP_PT_interpolate(bpy.types.Panel):
row.scale_y = 1.2
direction_button_row = row.row(align=True)
direction_button_row.scale_x = 3
ops_id = "gp.interpolate_stroke_tri" if settings.method == 'TRI' else "gp.interpolate_stroke"
ops_id = "gp.interpolate_stroke"
if settings.method == 'TRI':
ops_id = "gp.interpolate_stroke_tri"
elif settings.method == 'VELOCITY':
ops_id = "gp.interpolate_stroke_velocity"
direction_button_row.operator(ops_id, text=prev_text, icon=prev_icon).next = False
direction_button_row.operator(ops_id, text=next_text, icon=next_icon).next = True
@ -59,10 +65,17 @@ class GP_PT_interpolate(bpy.types.Panel):
elif settings.method == 'GEOMETRY':
col.prop(settings, 'search_range')
col.prop(settings, 'remove_occluded')
col.prop(settings, 'smooth_level', text='Smooth')
elif settings.method == 'OBJECT':
col.prop(settings, 'search_range')
col.prop(settings, 'target_object', text='Object')
col.prop(settings, 'smooth_level', text='Smooth')
elif settings.method == 'VELOCITY':
col.prop(settings, 'target_collection', text='Collection')
col.prop(settings, 'target_object', text='Object')
col.prop(settings, 'smooth_level', text='Smooth')
col.separator()
col = layout.column(align=True)

View File

@ -2,6 +2,9 @@ import bpy
import math
import numpy as np
import fnmatch
import os
from pathlib import Path
from math import tan
from mathutils import Vector, Matrix
@ -35,6 +38,63 @@ class attr_set():
# --- Vector
def load_datablock(filepath, *names, type='objects', link=True, expr=None, assets_only=False,
relative_to=None):
"""link or append elements from another blender scene
Args:
filepath (str): filepath of the scene to import objects from
names (list[str]): names of datablocks to import.
type (str, optional): type of data to import.
Defaults to 'objects'.
link (bool, optional): true if we want to import as link, else append.
Defaults to True.
expr (str, optional): pattern of names to import.
Defaults to None.
assets_only (bool, optional): If true, import only data-blocks marked as assets.
Defaults to False.
relative_to (str|Path|bool, optionnal): If str or Path and link make path relative to it
if False make path absolute, if None use preferences
Defaults to None.
Returns:
list|bpy.types.Object: datablocks imported
"""
# convert names from tuple to list to get the correct datablock type (blender tricks)
names = list(names)
if isinstance(expr, str):
pattern = expr
expr = lambda x: fnmatch(x, pattern)
with bpy.data.libraries.load(str(filepath), link=link, assets_only=assets_only) as (data_from, data_to):
datablocks = getattr(data_from, type)
if expr:
names += [i for i in datablocks if expr(i)]
elif not names:
names = datablocks
setattr(data_to, type, names)
datablocks = getattr(data_to, type)
if link and datablocks:
lib = datablocks[0].library
lib_path = os.path.abspath(bpy.path.abspath(lib.filepath))
if relative_to is False:
lib.filepath = lib_path
elif isinstance(relative_to, (str, Path)):
lib.filepath = bpy.path.relpath(lib_path, start=str(relative_to))
if len(names) > 1:
return datablocks
if datablocks:
return datablocks[0]
def triangle_normal(p1, p2, p3):
"""
Calculate the normal of a triangle given its three vertices.