40 Commits
Author SHA1 Message Date
Pullusb 9a4b965d51 Restore relative dir explorer props in preferences
5.0.1

- fixed: Restored preference paths as dir path (now need `PATH_SUPPORTS_BLEND_RELATIVE` option)
2026-01-13 09:29:05 +01:00
Pullusb 9d2644eb9f Update for blender 5+ with retro-compatibility
- Added version check to keep addon compatible with previous version
- Some feature are still broken (e.g: realign, broken since 4.3's gpv3)
- note: DIR_PATH string property do not accept relative path since Blender 5.0.
preferences brush_path,output_path and playblast_path had "//" by default. those props are turned to stnadard string for now
2025-12-15 23:57:57 +01:00
pullusb 9fd3b4af83 Fix delete view bound and add multiframe support
Also better report
2025-07-30 12:13:04 +02:00
pullusb a86a1fd409 merge stroke-point del into one ops - add in menu 2025-07-29 18:24:04 +02:00
pullusb 93b7ad91d8 Add special GP stroke deleter bound to vierw
4.2.0

- added: Delete Grease pencil strokes or points view bound (selection outside of viewport region is untouched)
  - No preset shortcut:
  - `gp.delete_points_view_bound` for points
  - `gp.delete_strokes_view_bound` for strokes
2025-07-29 17:41:07 +02:00
pullusb 9289b2ed6d add region keyword for better code navigation in utils 2025-07-29 17:40:03 +02:00
pullusb 2b198d03c9 Add popup with option to select duplicated strokes instead of removing 2025-06-17 18:01:01 +02:00
pullusb e93ee458b9 fix removed stroke frame number in prints 2025-06-17 17:38:08 +02:00
pullusb 2868bad3da fix verbose mode 2025-06-17 17:18:47 +02:00
pullusb a5dae7ee19 Fix redundant checker and add operator "Remove Redundant GP Stroke" (search) 2025-06-17 17:17:29 +02:00
pullusb 939b4f534f Fix file checker's redundant strokes delete
4.1.3

- fixed: error in "remove stroke duplication" using file checker
2025-06-17 16:57:20 +02:00
pullusb 3624f5cabd Fix #9 Draw cam overlay breking after undo
4.1.2

- fixed: Error in Draw cam overlay breaking after random undo step `Ctrl + Z`

use viewlayer viewport hide instead of object hide_viewport, otherwise transform seem to not be evaluated correctly
2025-06-04 14:01:26 +02:00
pullusb 34f8193c7b option to disable move baking in gp clipboard copy operator
4.1.1

- added: option in copy stroke world space to not bake move
2025-05-06 10:49:22 +02:00
pullusb aecef47d2f Add operator to list collection visibility conflicts
4.1.0
2025-03-04 12:35:45 +01:00
pullusb cf3f39b730 Respect group lock and hide state 2024-12-16 17:29:27 +01:00
pullusb 1c39d81ef1 update links for latest gpv2 release on readmes 2024-12-03 18:29:20 +01:00
pullusb 7665dd4f4f Add optional object target for constant cursor follow 2024-12-03 16:37:42 +01:00
pullusb 2013c55ba8 Fix broken cursor_follow feature 2024-12-03 16:10:30 +01:00
pullusb 3695470354 Fix problem in viz conflict check 2024-12-03 15:36:55 +01:00
pullusb 186229cdba typo 2024-12-03 14:36:48 +01:00
pullusb e441f485a6 version bump 2024-12-03 14:27:35 +01:00
pullusb 0cee6163aa Improve file checker
4.0.2

changed: File checker doest not fix directly when clicked:
  - List potential change and display an Apply fix
changed: Enhanced visibility conflict list:
  - Also include viewlayer hide value
  - Allow to set all hide value from the state of one of the three
2024-12-03 14:26:43 +01:00
pullusb 58e6816e39 Fix material move (fill opacity problem comes from blender 4.3 itself) 2024-12-02 14:51:43 +01:00
pullusb 4d6dc06e4e Remove useless pseudo groups now that there is a native group system 2024-12-02 11:39:17 +01:00
pullusb c8763f5ca4 add create empty frames to layer dropdowm menu 2024-11-28 12:29:27 +01:00
pullusb f9e7c9cc3b Fix layer nav operator
4.0.1

- fixed: layer nav operator on page up/down
2024-11-28 12:26:33 +01:00
pullusb 86fb848e4a fix batch reproject frame issue 2024-11-27 18:01:18 +01:00
pullusb abb61ca6d4 Initial code for better cursor_follow behavior (wip) 2024-11-27 16:43:41 +01:00
pullusb 7430bca02f Fix modifiers access 2024-11-27 16:40:31 +01:00
pullusb f5c20a3499 fix bad initialized value in clipboard paste.
4.3 do not  initialize points opacity and radius as expected
2024-11-26 16:30:21 +01:00
pullusb edfefa874a robust attribute copy for duplicate frames 2024-11-26 14:16:29 +01:00
pullusb 6dbf666ee3 Fix duplicate-send to to layer 2024-11-21 15:11:06 +01:00
pullusb d3e4072564 wip copy-move keys 2024-11-14 19:09:52 +01:00
pullusb 63f377d7d1 Fix material picker keymap 2024-11-14 18:02:24 +01:00
pullusb 49c70860a6 gpv3 port: create empty frames - add support for group as source
add utility function to check hide-lock state of nested items
2024-11-14 17:27:57 +01:00
pullusb b525cda28e gpv3 : pick closest layer from stroke 2024-11-14 16:18:56 +01:00
pullusb 05053cff68 fix copy paste layers 2024-11-14 16:11:31 +01:00
pullusb 4937aa32c0 gpv3 world copy cut paste 2024-11-13 18:58:28 +01:00
pullusb 5d930df06b add waning message in readme 2024-11-13 11:10:51 +01:00
pullusb 5d35074d3d fix get addons pref
remove edit line opacity
fix menus
2024-11-12 19:06:57 +01:00
28 changed files with 1495 additions and 735 deletions
+54
View File
@@ -1,5 +1,59 @@
# Changelog
5.0.1
- fixed: Restored preference paths as dir path (now need `PATH_SUPPORTS_BLEND_RELATIVE` option)
5.0.0
- added: Update for blender 5+. With retro-compatibility
4.2.0
- added: Delete Grease pencil strokes or points view bound (selection outside of viewport region is untouched)
- No preset shortcut
<!-- - `grease_pencil.delete_points_view_bound` for points -->
<!-- - `grease_pencil.delete_strokes_view_bound` for strokes -->
- operator id_name: `grease_pencil.delete_view_bound`
- Added to Delete menu
- support multiframe
- Only do a dissolve (delete for full stroke)
4.1.3
- fixed: error in "remove stroke duplication" using file checker
- added: `Remove Redundant GP Stroke` Operator (Not exposed, avaialbe in search with developer extras enabled). Allow to use directly without going using file checker
4.1.2
- fixed: Error in Draw cam overlay breaking after random undo step `Ctrl + Z`
4.1.1
- added: option in copy stroke world space to not bake move
4.1.0
- added: operator `list collection visibility conflicts` (not exposed, called from search menu with dev extras enabled in prefs)
4.0.3
changed: File checker doest not fix directly when clicked (also removed choice in preference):
- list potential change and display an `Apply Fix`
changed: Enhanced visibility conflict list:
- also include viewlayer hide value
- allow to set all hide value from the state of one of the three
- fixed: material move operator
4.0.1
- fixed: layer nav operator on page up/down
4.0.0
- changed: version for Blender 4.3 - Breaking retrocompatibility with previous.
3.3.0
- added: `Move Material To Layer` has now option to copy instead of moving in pop-up menu.
+58 -15
View File
@@ -5,24 +5,37 @@ from bpy.props import (FloatProperty,
EnumProperty,
StringProperty,
IntProperty)
from .. import utils
from ..utils import is_hidden
## copied from OP_key_duplicate_send
def get_layer_list(self, context):
'''return (identifier, name, description) of enum content'''
return [(l.name, l.name, '') for l in context.object.data.layers if l != context.object.data.layers.active]
def get_group_list(self, context):
return [(g.name, g.name, '') for g in context.object.data.layer_groups]
class GP_OT_create_empty_frames(bpy.types.Operator):
bl_idname = "gp.create_empty_frames"
bl_label = "Create Empty Frames"
bl_description = "Create new empty frames on active layer where there is a frame in layer above\n(usefull in color layers to match line frames)"
bl_description = "Create new empty frames on active layer where there is a frame in targeted layers\
\n(usefull in color layers to match line frames)"
bl_options = {'REGISTER','UNDO'}
layers_enum : EnumProperty(
name="Duplicate to layers",
description="Duplicate selected keys in active layer and send them to choosen layer",
name="Empty Keys from Layer",
description="Reference keys from layer",
items=get_layer_list
)
groups_enum : EnumProperty(
name="Empty Keys from Group",
description="Duplicate keys from group",
items=get_group_list
)
targeted_layers : EnumProperty(
name="Sources", # Empty keys from targets
description="Duplicate keys as empty on current layer from selected targets",
@@ -34,7 +47,8 @@ class GP_OT_create_empty_frames(bpy.types.Operator):
('ABOVE', 'Layer Directly Above', 'Empty frames from layer directly above'),
('BELOW', 'Layer Directly Below', 'Empty frames from layer directly below'),
('ALL_VISIBLE', 'Visible', 'Empty frames from all visible layers'),
('CHOSEN', 'Chosen layer', ''),
('CHOSEN', 'Chosen layer', 'Empty frames from a specific layer'),
('CHOSEN_GROUP', 'Chosen group', 'Empty frames from a specific layer group'),
)
)
@@ -63,6 +77,14 @@ class GP_OT_create_empty_frames(bpy.types.Operator):
# Possible preset with shortcut
# if event.alt:
# self.targeted_layers = 'ALL_VISIBLE'
gp = context.grease_pencil
layer_from_group = None
if gp.layer_groups.active:
layer_from_group = utils.get_top_layer_from_group(gp, gp.layer_groups.active)
## Can just do if not utils.get_closest_active_layer(context.grease_pencil):
if not gp.layers.active and not layer_from_group:
self.report({'ERROR'}, 'No active layer or active group containing layer on GP object')
return {'CANCELLED'}
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
@@ -74,7 +96,13 @@ class GP_OT_create_empty_frames(bpy.types.Operator):
if self.layers_enum:
layout.prop(self, 'layers_enum')
else:
layout.label(text='No other layers to match keyframe')
layout.label(text='No other layers to match keyframe!', icon='ERROR')
if self.targeted_layers == 'CHOSEN_GROUP':
if self.groups_enum:
layout.prop(self, 'groups_enum')
else:
layout.label(text='No other groups to match keyframe!', icon='ERROR')
elif self.targeted_layers == 'NUMBER':
row = layout.row()
@@ -91,23 +119,31 @@ class GP_OT_create_empty_frames(bpy.types.Operator):
def execute(self, context):
obj = context.object
gpl = obj.data.layers
gp = obj.data
gpl = gp.layers
if gp.layer_groups.active:
reference_layer = utils.get_top_layer_from_group(gp, gp.layer_groups.active)
else:
reference_layer = gpl.active
active_index = next((i for i, l in enumerate(gpl) if l == reference_layer), None)
print(self.targeted_layers)
if self.targeted_layers == 'ALL_ABOVE':
tgt_layers = [l for i, l in enumerate(gpl) if i > gpl.active_index]
tgt_layers = [l for i, l in enumerate(gpl) if i > active_index]
elif self.targeted_layers == 'ALL_BELOW':
tgt_layers = [l for i, l in enumerate(gpl) if i < gpl.active_index]
tgt_layers = [l for i, l in enumerate(gpl) if i < active_index]
elif self.targeted_layers == 'ABOVE':
tgt_layers = [l for i, l in enumerate(gpl) if i == gpl.active_index + 1]
tgt_layers = [l for i, l in enumerate(gpl) if i == active_index + 1]
elif self.targeted_layers == 'BELOW':
tgt_layers = [l for i, l in enumerate(gpl) if i == gpl.active_index - 1]
tgt_layers = [l for i, l in enumerate(gpl) if i == active_index - 1]
elif self.targeted_layers == 'ALL_VISIBLE':
tgt_layers = [l for l in gpl if not l.hide and l != gpl.active]
tgt_layers = [l for l in gpl if not is_hidden(l) and l != gpl.active]
elif self.targeted_layers == 'CHOSEN':
if not self.layers_enum:
@@ -115,17 +151,24 @@ class GP_OT_create_empty_frames(bpy.types.Operator):
return {'CANCELLED'}
tgt_layers = [l for l in gpl if l.name == self.layers_enum]
elif self.targeted_layers == 'CHOSEN_GROUP':
if not self.groups_enum:
self.report({'ERROR'}, f"No chosen groups, aborted")
return {'CANCELLED'}
group = gp.layer_groups.get(self.groups_enum)
tgt_layers = [l for l in gpl if l.parent_group == group]
elif self.targeted_layers == 'NUMBER':
if self.number == 0:
self.report({'ERROR'}, f"Can't have 0 as value")
return {'CANCELLED'}
l_range = gpl.active_index + self.number
l_range = active_index + self.number
print('l_range: ', l_range)
if self.number > 0: # positive
tgt_layers = [l for i, l in enumerate(gpl) if gpl.active_index < i <= l_range]
tgt_layers = [l for i, l in enumerate(gpl) if active_index < i <= l_range]
else:
tgt_layers = [l for i, l in enumerate(gpl) if gpl.active_index > i >= l_range]
tgt_layers = [l for i, l in enumerate(gpl) if active_index > i >= l_range]
if not tgt_layers:
self.report({'ERROR'}, f"No layers found with chosen Targets")
@@ -163,7 +206,7 @@ class GP_OT_create_empty_frames(bpy.types.Operator):
if num in current_frames:
continue
#Create empty frame
gpl.active.frames.new(num, active=False)
gpl.active.frames.new(num)
fct += 1
gpl.update()
+9 -6
View File
@@ -5,11 +5,14 @@ from ..utils import (location_to_region,
vector_length,
draw_gp_stroke,
extrapolate_points_by_length,
simple_draw_gp_stroke)
simple_draw_gp_stroke,
is_hidden,
is_locked)
import bpy
from math import degrees
from mathutils import Vector
# from os.path import join, basename, exists, dirname, abspath, splitext
# iterate over selected layer and all/selected frame and close gaps between line extermities with a tolerance level
@@ -276,9 +279,9 @@ class GPSTK_OT_extend_lines(bpy.types.Operator):
if self.layer_tgt == 'ACTIVE':
lays = [ob.data.layers.active]
elif self.layer_tgt == 'SELECTED':
lays = [l for l in ob.data.layers if l.select and not l.hide]
lays = [l for l in ob.data.layers if l.select and not is_hidden(l)]
elif self.layer_tgt == 'ALL_VISIBLE':
lays = [l for l in ob.data.layers if not l.hide]
lays = [l for l in ob.data.layers if not is_hidden(l)]
else:
lays = [l for l in ob.data.layers if not any(x in l.name for x in ('spot', 'colo'))]
@@ -337,9 +340,9 @@ class GPSTK_OT_change_closeline_length(bpy.types.Operator):
if self.layer_tgt == 'ACTIVE':
lays = [ob.data.layers.active]
elif self.layer_tgt == 'SELECTED':
lays = [l for l in ob.data.layers if l.select and not l.hide]
lays = [l for l in ob.data.layers if l.select and not is_hidden(l)]
elif self.layer_tgt == 'ALL_VISIBLE':
lays = [l for l in ob.data.layers if not l.hide]
lays = [l for l in ob.data.layers if not is_hidden(l)]
else:
lays = [l for l in ob.data.layers if not any(x in l.name for x in ('spot', 'colo'))]
@@ -375,7 +378,7 @@ class GPSTK_OT_comma_finder(bpy.types.Operator):
def execute(self, context):
ct = 0
ob = context.object
lays = [l for l in ob.data.layers if not l.hide and not l.lock]
lays = [l for l in ob.data.layers if not is_hidden(l) and not is_locked(l)]
for l in lays:
if not l.current_frame():continue
for s in l.current_frame().drawing.strokes:
+4 -3
View File
@@ -3,6 +3,7 @@ import bpy
import re
from mathutils import Vector, Matrix
from math import radians, degrees
from . import utils
# exemple for future improve: https://justinsbarrett.com/tweenmachine/
@@ -21,7 +22,7 @@ def get_surrounding_points(fc, frame):
return p_pt, n_pt
## unused direct breackdown func
## unused direct breakdown func
def breakdown_keys(percentage=50, channels=('location', 'rotation_euler', 'scale'), axe=(0,1,2)):
cf = bpy.context.scene.frame_current# use operator context (may be unsynced timeline)
axes_name = ('x', 'y', 'z')
@@ -42,7 +43,7 @@ def breakdown_keys(percentage=50, channels=('location', 'rotation_euler', 'scale
skipping = []
for fc in action.fcurves:
for fc in utils.get_fcurves(obj):
# if fc.data_path.split('"')[1] in bone_names_filter:# bones
# if fc.data_path.split('.')[-1] in channels and fc.array_index in axe:# bones
if fc.data_path in channels and fc.array_index in axe:# .split('.')[-1]
@@ -252,7 +253,7 @@ class OBJ_OT_breakdown_obj_anim(bpy.types.Operator):
## TODO for ob in context.selected objects, need to reduce list with upper filters...
for fc in action.fcurves:
for fc in utils.get_fcurves(obj):
# if fc.data_path.split('"')[1] in bone_names_filter:# bones
# if fc.data_path.split('.')[-1] in channels and fc.array_index in axe:# bones
if fc.data_path in self.channels:# .split('.')[-1]# and fc.array_index in axe
+133 -162
View File
@@ -1,38 +1,15 @@
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
## based on GPclipboard 1.3.2 (just stripped addon prefs)
bl_info = {
"name": "GP clipboard",
"description": "Copy/Cut/Paste Grease Pencil strokes to/from OS clipboard across layers and blends",
"author": "Samuel Bernou",
"version": (1, 3, 3),
"blender": (2, 83, 0),
"location": "View3D > Toolbar > Gpencil > GP clipboard",
"warning": "",
"doc_url": "https://github.com/Pullusb/GP_clipboard",
"category": "Object" }
## GP clipboard : Copy/Cut/Paste Grease Pencil strokes to/from OS clipboard across layers and blends
## View3D > Toolbar > Gpencil > GP clipboard
## in 4.2, existed in standalone scripts: https://github.com/Pullusb/GP_clipboard
import bpy
import os
import mathutils
from mathutils import Vector
import json
from time import time
from operator import itemgetter
from itertools import groupby
# from pprint import pprint
from .utils import is_locked, is_hidden
def convertAttr(Attr):
'''Convert given value to a Json serializable format'''
@@ -48,101 +25,103 @@ def convertAttr(Attr):
def getMatrix(layer) :
matrix = mathutils.Matrix.Identity(4)
if layer.is_parented:
if layer.parent_type == 'BONE':
object = layer.parent
bone = object.pose.bones[layer.parent_bone]
matrix = bone.matrix @ object.matrix_world
matrix = matrix.copy() @ layer.matrix_inverse
if parent := layer.parent:
if parent.type == 'ARMATURE' and layer.parent_bone:
bone = parent.pose.bones[layer.parent_bone]
matrix = bone.matrix @ parent.matrix_world
matrix = matrix.copy() @ layer.matrix_parent_inverse
else:
matrix = layer.parent.matrix_world @ layer.matrix_inverse
matrix = parent.matrix_world @ layer.matrix_parent_inverse
return matrix.copy()
default_pt_uv_fill = Vector((0.5, 0.5))
# default_pt_uv_fill = Vector((0.5, 0.5))
def dump_gp_point(p, l, obj,
pressure=True, strength=True, vertex_color=True, uv_fill=True, uv_factor=True, uv_rotation=True):
radius=True, opacity=True, vertex_color=True, fill_color=True, uv_factor=True, rotation=True):
'''add properties of a given points to a dic and return it'''
pdic = {}
#point_attr_list = ('co', 'pressure', 'select', 'strength') #select#'rna_type'
point_dict = {}
#point_attr_list = ('co', 'radius', 'select', 'opacity') #select#'rna_type'
#for att in point_attr_list:
# pdic[att] = convertAttr(getattr(p, att))
# point_dict[att] = convertAttr(getattr(p, att))
if l.parent:
mat = getMatrix(l)
pdic['co'] = convertAttr(obj.matrix_world @ mat @ getattr(p,'co'))
point_dict['position'] = convertAttr(obj.matrix_world @ mat @ getattr(p,'position'))
else:
pdic['co'] = convertAttr(obj.matrix_world @ getattr(p,'co'))
point_dict['position'] = convertAttr(obj.matrix_world @ getattr(p,'position'))
# pdic['select'] = convertAttr(getattr(p,'select')) # need selection ?
if pressure and p.pressure != 1.0:
pdic['pressure'] = convertAttr(getattr(p,'pressure'))
if strength and p.strength != 1.0:
pdic['strength'] = convertAttr(getattr(p,'strength'))
# point_dict['select'] = convertAttr(getattr(p,'select')) # need selection ?
if radius and p.radius != 1.0:
point_dict['radius'] = convertAttr(getattr(p,'radius'))
if opacity and p.opacity != 1.0:
point_dict['opacity'] = convertAttr(getattr(p,'opacity'))
## get vertex color (long...)
if vertex_color and p.vertex_color[:] != (0.0, 0.0, 0.0, 0.0):
pdic['vertex_color'] = convertAttr(p.vertex_color)
point_dict['vertex_color'] = convertAttr(p.vertex_color)
## UV attr (maybe uv fill is always (0.5,0.5) ? also exists at stroke level...)
if uv_fill and p.uv_fill != default_pt_uv_fill:
pdic['uv_fill'] = convertAttr(p.uv_fill)
if uv_factor and p.uv_factor != 0.0:
pdic['uv_factor'] = convertAttr(p.uv_factor)
if uv_rotation and p.uv_rotation != 0.0:
pdic['uv_rotation'] = convertAttr(p.uv_rotation)
if rotation and p.rotation != 0.0:
point_dict['rotation'] = convertAttr(p.rotation)
return pdic
## No time infos
# if delta_time and p.delta_time != 0.0:
# point_dict['delta_time'] = convertAttr(getattr(p,'delta_time'))
return point_dict
def dump_gp_stroke_range(s, sid, l, obj,
pressure=True, strength=True, vertex_color=True, uv_fill=True, uv_factor=True, uv_rotation=True):
radius=True, opacity=True, vertex_color=True, fill_color=True, fill_opacity=True, rotation=True):
'''Get a grease pencil stroke and return a dic with attribute
(points attribute being a dic of dics to store points and their attributes)
'''
sdic = {}
stroke_attr_list = ('line_width',) #'select'#read-only: 'triangles'
for att in stroke_attr_list:
sdic[att] = getattr(s, att)
stroke_dict = {}
# stroke_attr_list = ('line_width',)
# for att in stroke_attr_list:
# stroke_dict[att] = getattr(s, att)
## Dump following these value only if they are non default
if s.material_index != 0:
sdic['material_index'] = s.material_index
stroke_dict['material_index'] = s.material_index
if getattr(s, 'draw_cyclic', None): # pre-2.92
sdic['draw_cyclic'] = s.draw_cyclic
if s.cyclic:
stroke_dict['cyclic'] = s.cyclic
if getattr(s, 'use_cyclic', None): # from 2.92
sdic['use_cyclic'] = s.use_cyclic
if s.softness != 0.0:
stroke_dict['softness'] = s.softness
if s.uv_scale != 1.0:
sdic['uv_scale'] = s.uv_scale
if s.aspect_ratio != 1.0:
stroke_dict['aspect_ratio'] = s.aspect_ratio
if s.uv_rotation != 0.0:
sdic['uv_rotation'] = s.uv_rotation
if s.start_cap != 0:
stroke_dict['start_cap'] = s.start_cap
if s.hardness != 1.0:
sdic['hardness'] = s.hardness
if s.end_cap != 0:
stroke_dict['end_cap'] = s.end_cap
if s.uv_translation != Vector((0.0, 0.0)):
sdic['uv_translation'] = convertAttr(s.uv_translation)
if fill_color and s.fill_color[:] != (0,0,0,0):
stroke_dict['fill_color'] = convertAttr(s.fill_color)
if s.vertex_color_fill[:] != (0,0,0,0):
sdic['vertex_color_fill'] = convertAttr(s.vertex_color_fill)
if fill_opacity and s.fill_opacity != 0.0:
stroke_dict['fill_opacity'] = s.fill_opacity
## No time infos
# if s.time_start != 0.0:
# stroke_dict['time_start'] = s.time_start
points = []
if sid is None: # no ids, just full points...
for p in s.points:
points.append(dump_gp_point(p, l, obj,
pressure=pressure, strength=strength, vertex_color=vertex_color, uv_fill=uv_fill, uv_factor=uv_factor, uv_rotation=uv_rotation))
radius=radius, opacity=opacity, vertex_color=vertex_color, rotation=rotation))
else:
for pid in sid:
points.append(dump_gp_point(s.points[pid], l, obj,
pressure=pressure, strength=strength, vertex_color=vertex_color, uv_fill=uv_fill, uv_factor=uv_factor, uv_rotation=uv_rotation))
sdic['points'] = points
return sdic
radius=radius, opacity=opacity, vertex_color=vertex_color, rotation=rotation))
stroke_dict['points'] = points
return stroke_dict
def copycut_strokes(layers=None, copy=True, keep_empty=True):
@@ -161,7 +140,7 @@ def copycut_strokes(layers=None, copy=True, keep_empty=True):
# color = gp.palettes.active.colors.active.name
if not layers:
# by default all visible layers
layers = [l for l in gpl if not l.hide and not l.lock] # []
layers = [l for l in gpl if not is_hidden(l) and not is_locked(l)] # []
if not isinstance(layers, list):
# if a single layer object is send put in a list
layers = [layers]
@@ -175,26 +154,30 @@ def copycut_strokes(layers=None, copy=True, keep_empty=True):
if not copy:
staylist = [] # init part of strokes that must survive on this layer
for s in f.drawing.strokes:
if s.select:
rm_list = [] # init strokes that must be removed from this layer
for s_index, stroke in enumerate(f.drawing.strokes):
if stroke.select:
# separate in multiple stroke if parts of the strokes a selected.
sel = [i for i, p in enumerate(s.points) if p.select]
sel = [i for i, p in enumerate(stroke.points) if p.select]
substrokes = [] # list of list containing isolated selection
for k, g in groupby(enumerate(sel), lambda x:x[0]-x[1]):# continuity stroke have same substract result between point index and enumerator
# continuity stroke have same substract result between point index and enumerator
for k, g in groupby(enumerate(sel), lambda x:x[0]-x[1]):
group = list(map(itemgetter(1), g))
substrokes.append(group)
for ss in substrokes:
if len(ss) > 1: # avoid copy isolated points
stroke_list.append(dump_gp_stroke_range(s,ss,l,obj))
stroke_list.append(dump_gp_stroke_range(stroke, ss, l, obj))
# Cutting operation
if not copy:
maxindex = len(s.points)-1
maxindex = len(stroke.points)-1
if len(substrokes) == maxindex+1: # if only one substroke, then it's the full stroke
f.drawing.strokes.remove(s)
# f.drawing.strokes.remove(stroke) # gpv2
rm_list.append(s_index)
else:
neg = [i for i, p in enumerate(s.points) if not p.select]
neg = [i for i, p in enumerate(stroke.points) if not p.select]
staying = []
for k, g in groupby(enumerate(neg), lambda x:x[0]-x[1]):
@@ -208,37 +191,30 @@ def copycut_strokes(layers=None, copy=True, keep_empty=True):
for ns in staying:
if len(ns) > 1:
staylist.append(dump_gp_stroke_range(s,ns,l,obj))
staylist.append(dump_gp_stroke_range(stroke, ns, l, obj))
# make a negative list containing all last index
'''#full stroke version
# if s.colorname == color: #line for future filters
stroke_list.append(dump_gp_stroke(s,l))
#delete stroke on the fly
if not copy:
f.drawing.strokes.remove(s)
'''
if rm_list:
f.drawing.remove_strokes(indices=rm_list)
if not copy:
selected_ids = [i for i, s in enumerate(f.drawing.strokes) if s.select]
# delete all selected strokes...
for s in f.drawing.strokes:
if s.select:
f.drawing.strokes.remove(s)
if selected_ids:
f.drawing.remove_strokes(indices=selected_ids)
# ...recreate these uncutted ones
#pprint(staylist)
if staylist:
add_multiple_strokes(staylist, l)
#for ns in staylist:#weirdly recreate the stroke twice !
# add_stroke(ns, f, l)
#if nothing left on the frame choose to leave an empty frame or delete it (let previous frame appear)
if not copy and not keep_empty:#
# If nothing left on the frame choose to leave an empty frame or delete it (let previous frame appear)
if not copy and not keep_empty:
if not len(f.drawing.strokes):
l.frames.remove(f)
print(len(stroke_list), 'strokes copied in', time()-t0, 'seconds')
#print(stroke_list)
return stroke_list
@@ -260,7 +236,7 @@ def copy_all_strokes(layers=None):
if not layers:
# by default all visible layers
layers = [l for l in gpl if not l.hide and not l.lock]# include locked ?
layers = [l for l in gpl if not is_hidden(l) and not is_locked(l)]# include locked ?
if not isinstance(layers, list):
# if a single layer object is send put in a list
layers = [layers]
@@ -284,7 +260,7 @@ def copy_all_strokes(layers=None):
"""
def copy_all_strokes_in_frame(frame=None, layers=None, obj=None,
pressure=True, strength=True, vertex_color=True, uv_fill=True, uv_factor=True, uv_rotation=True):
radius=True, opacity=True, vertex_color=True, fill_color=True, fill_opacity=True, rotation=True):
'''
copy all stroke, not affected by selection on active frame
layers can be None, a single layer object or list of layer object as filter
@@ -300,7 +276,7 @@ def copy_all_strokes_in_frame(frame=None, layers=None, obj=None,
if not layers:
# by default all visible layers
layers = [l for l in gpl if not l.hide and not l.lock] # include locked ?
layers = [l for l in gpl if not is_hidden(l) and not is_locked(l)] # include locked ?
if not isinstance(layers, list):
# if a single layer object is send put in a list
layers = [layers]
@@ -318,7 +294,7 @@ def copy_all_strokes_in_frame(frame=None, layers=None, obj=None,
# if s.select:
# send index of all points to get the whole stroke with "range"
stroke_list.append( dump_gp_stroke_range(s, [i for i in range(len(s.points))], l, obj,
pressure=pressure,strength=strength,vertex_color=vertex_color,uv_fill=uv_fill,uv_factor=uv_factor,uv_rotation=uv_rotation))
radius=radius, opacity=opacity, vertex_color=vertex_color, fill_color=fill_color, fill_opacity=fill_opacity, rotation=rotation))
# print(len(stroke_list), 'strokes copied in', time()-t0, 'seconds')
return stroke_list
@@ -326,49 +302,43 @@ def copy_all_strokes_in_frame(frame=None, layers=None, obj=None,
def add_stroke(s, frame, layer, obj, select=False):
'''add stroke on a given frame, (layer is for parentage setting)'''
# print(3*'-',s)
ns = frame.drawing.strokes.new()
pts_to_add = len(s['points'])
frame.drawing.add_strokes([pts_to_add])
ns = frame.drawing.strokes[-1]
## set strokes atrributes
for att, val in s.items():
if att not in ('points'):
setattr(ns, att, val)
pts_to_add = len(s['points'])
# print(pts_to_add, 'points')#dbg
ns.points.add(pts_to_add)
ob_mat_inv = obj.matrix_world.inverted()
## patch pressure 1
# pressure_flat_list = [di['pressure'] for di in s['points']] #get all pressure flatened
if layer.parent:
layer_matrix = getMatrix(layer).inverted()
transform_matrix = ob_mat_inv @ layer_matrix
else:
transform_matrix = ob_mat_inv
if layer.is_parented:
mat = getMatrix(layer).inverted()
## Set points attributes
for i, pt in enumerate(s['points']):
for k, v in pt.items():
if k == 'co':
if k == 'position':
setattr(ns.points[i], k, v)
ns.points[i].position = ob_mat_inv @ mat @ ns.points[i].position # invert of object * invert of layer * coordinate
ns.points[i].position = transform_matrix @ ns.points[i].position # invert of object * invert of layer * coordinate
else:
setattr(ns.points[i], k, v)
if select:
ns.points[i].select = True
else:
for i, pt in enumerate(s['points']):
for k, v in pt.items():
if k == 'co':
setattr(ns.points[i], k, v)
ns.points[i].position = ob_mat_inv @ ns.points[i].position# invert of object * coordinate
else:
setattr(ns.points[i], k, v)
if select:
ns.points[i].select = True
## Opacity initialized at 0.0 (should be 1.0)
if not 'opacity' in pt:
ns.points[i].opacity = 1.0
## trigger updapte (in 2.93 fix some drawing problem with fills and UVs)
ns.points.update()
## Radius initialized at 0.0 (should probably be 0.01)
if not 'radius' in pt:
ns.points[i].radius = 0.01
## patch pressure 2
# ns.points.foreach_set('pressure', pressure_flat_list)
def add_multiple_strokes(stroke_list, layer=None, use_current_frame=True, select=False):
'''
@@ -404,10 +374,6 @@ def add_multiple_strokes(stroke_list, layer=None, use_current_frame=True, select
for s in stroke_list:
add_stroke(s, target_frame, layer, obj, select=select)
'''
for s in stroke_data:
add_stroke(s, target_frame)
'''
# print(len(stroke_list), 'strokes pasted')
@@ -431,7 +397,7 @@ class GPCLIP_OT_copy_strokes(bpy.types.Operator):
# return {"CANCELLED"}
t0 = time()
#ct = check_pressure()
#ct = check_radius()
strokelist = copycut_strokes(copy=True, keep_empty=True)
if not strokelist:
self.report({'ERROR'}, 'Nothing to copy')
@@ -448,7 +414,7 @@ class GPCLIP_OT_cut_strokes(bpy.types.Operator):
bl_idname = "gp.cut_strokes"
bl_label = "GP Cut strokes"
bl_description = "Cut strokes to text in paperclip"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
@@ -460,7 +426,7 @@ class GPCLIP_OT_cut_strokes(bpy.types.Operator):
# return {"CANCELLED"}
t0 = time()
strokelist = copycut_strokes(copy=False, keep_empty=True) # ct = check_pressure()
strokelist = copycut_strokes(copy=False, keep_empty=True) # ct = check_radius()
if not strokelist:
self.report({'ERROR'},'Nothing to cut')
return {"CANCELLED"}
@@ -512,18 +478,23 @@ class GPCLIP_OT_copy_multi_strokes(bpy.types.Operator):
def poll(cls, context):
return context.object and context.object.type == 'GREASEPENCIL'
pressure : bpy.props.BoolProperty(name='pressure', default=True,
description='Dump point pressure attribute (already skipped if at default value)')
strength : bpy.props.BoolProperty(name='strength', default=True,
description='Dump point strength attribute (already skipped if at default value)')
bake_moves : bpy.props.BoolProperty(name='Bake Move', default=True,
description='Copy every frame where object has moved, else copy only existing frames')
radius : bpy.props.BoolProperty(name='radius', default=True,
description='Dump point radius attribute (already skipped if at default value)')
opacity : bpy.props.BoolProperty(name='opacity', default=True,
description='Dump point opacity attribute (already skipped if at default value)')
vertex_color : bpy.props.BoolProperty(name='vertex color', default=True,
description='Dump point vertex_color attribute (already skipped if at default value)')
uv_fill : bpy.props.BoolProperty(name='uv fill', default=True,
description='Dump point uv_fill attribute (already skipped if at default value)')
fill_color : bpy.props.BoolProperty(name='fill color', default=True,
description='Dump point fill_color attribute (already skipped if at default value)')
fill_opacity : bpy.props.BoolProperty(name='fill opacity', default=True,
description='Dump point fill_opacity attribute (already skipped if at default value)')
uv_factor : bpy.props.BoolProperty(name='uv factor', default=True,
description='Dump point uv_factor attribute (already skipped if at default value)')
uv_rotation : bpy.props.BoolProperty(name='uv rotation', default=True,
description='Dump point uv_rotation attribute (already skipped if at default value)')
rotation : bpy.props.BoolProperty(name='rotation', default=True,
description='Dump point rotation attribute (already skipped if at default value)')
def invoke(self, context, event):
# self.file_dump = event.ctrl
@@ -534,32 +505,32 @@ class GPCLIP_OT_copy_multi_strokes(bpy.types.Operator):
layout=self.layout
layout.use_property_split = True
col = layout.column()
col.prop(self, 'bake_moves')
col.label(text='Keep following point attributes:')
col.prop(self, 'pressure')
col.prop(self, 'strength')
col.prop(self, 'radius')
col.prop(self, 'opacity')
col.prop(self, 'vertex_color')
col.prop(self, 'uv_fill')
col.prop(self, 'uv_factor')
col.prop(self, 'uv_rotation')
col.prop(self, 'fill_color')
col.prop(self, 'fill_opacity')
col.prop(self, 'rotation')
return
def execute(self, context):
bake_moves = True
skip_empty_frame = False
org_frame = context.scene.frame_current
obj = context.object
gpl = obj.data.layers
t0 = time()
#ct = check_pressure()
#ct = check_radius()
layerdic = {}
layerpool = [l for l in gpl if not l.hide and l.select] # and not l.lock
layerpool = [l for l in gpl if not is_hidden(l) and l.select] # and not is_locked(l)
if not layerpool:
self.report({'ERROR'}, 'No layers selected in GP dopesheet (needs to be visible and selected to be copied)\nHint: Changing active layer reset selection to active only')
return {"CANCELLED"}
if not bake_moves: # copy only drawed frames as is.
if not self.bake_moves: # copy only drawed frames as is.
for l in layerpool:
if not l.frames:
continue # skip empty layers
@@ -570,8 +541,8 @@ class GPCLIP_OT_copy_multi_strokes(bpy.types.Operator):
continue
context.scene.frame_set(f.frame_number) # use matrix of this frame
strokelist = copy_all_strokes_in_frame(frame=f, layers=l, obj=obj,
pressure=self.pressure, strength=self.strength, vertex_color=self.vertex_color,
uv_fill=self.uv_fill, uv_factor=self.uv_factor, uv_rotation=self.uv_rotation)
radius=self.radius, opacity=self.opacity, vertex_color=self.vertex_color,
fill_color=self.fill_color, fill_opacity=self.fill_opacity, rotation=self.rotation)
frame_dic[f.frame_number] = strokelist
@@ -607,8 +578,8 @@ class GPCLIP_OT_copy_multi_strokes(bpy.types.Operator):
continue
strokelist = copy_all_strokes_in_frame(frame=f, layers=l, obj=obj,
pressure=self.pressure, strength=self.strength, vertex_color=self.vertex_color,
uv_fill=self.uv_fill, uv_factor=self.uv_factor, uv_rotation=self.uv_rotation)
radius=self.radius, opacity=self.opacity, vertex_color=self.vertex_color,
fill_color=self.fill_color, fill_opacity=self.fill_opacity, rotation=self.rotation)
frame_dic[i] = strokelist
+59 -12
View File
@@ -2,6 +2,8 @@
import bpy
import mathutils
from bpy_extras import view3d_utils
from bpy.app.handlers import persistent
from .utils import get_gp_draw_plane, region_to_location, get_view_origin_position
## override all sursor snap shortcut with this in keymap
@@ -105,20 +107,24 @@ def swap_keymap_by_id(org_idname, new_idname):
k.idname = new_idname
# prev_matrix = mathutils.Matrix()
prev_matrix = None
# @call_once(bpy.app.handlers.frame_change_post)
## used in properties file to register in boolprop update
def cursor_follow_update(self, context):
'''append or remove cursor_follow handler according a boolean'''
ob = bpy.context.object
if bpy.context.scene.gptoolprops.cursor_follow_target:
## override with target object is specified
ob = bpy.context.scene.gptoolprops.cursor_follow_target
global prev_matrix
# imported in properties to register in boolprop update
if self.cursor_follow:#True
if ob:
# out of below condition to be called when setting target as well
prev_matrix = ob.matrix_world.copy()
if not cursor_follow.__name__ in [hand.__name__ for hand in bpy.app.handlers.frame_change_post]:
if context.object:
prev_matrix = context.object.matrix_world
bpy.app.handlers.frame_change_post.append(cursor_follow)
else:#False
@@ -129,11 +135,13 @@ def cursor_follow_update(self,context):
def cursor_follow(scene):
'''Handler to make the cursor follow active object matrix changes on frame change'''
## TODO update global prev_matrix to equal current_matrix on selection change (need another handler)...
if not bpy.context.object:
ob = bpy.context.object
if bpy.context.scene.gptoolprops.cursor_follow_target:
## override with target object is specified
ob = bpy.context.scene.gptoolprops.cursor_follow_target
if not ob:
return
global prev_matrix
ob = bpy.context.object
current_matrix = ob.matrix_world
if not prev_matrix:
prev_matrix = current_matrix.copy()
@@ -147,14 +155,47 @@ def cursor_follow(scene):
## translation only
# scene.cursor.location += (current_matrix - prev_matrix).to_translation()
# print('offset:', (current_matrix - prev_matrix).to_translation())
## full
scene.cursor.location = current_matrix @ (prev_matrix.inverted() @ scene.cursor.location)
# store for next use
prev_matrix = current_matrix.copy()
prev_active_obj = None
## Add check for object selection change
def selection_changed():
"""Callback function for selection changes"""
if not bpy.context.scene.gptoolprops.cursor_follow:
return
if bpy.context.scene.gptoolprops.cursor_follow_target:
# we are following a target, nothing to update on selection change
return
global prev_matrix, prev_active_obj
if prev_active_obj != bpy.context.object:
## Set stored matrix to active object
prev_matrix = bpy.context.object.matrix_world.copy()
prev_active_obj = bpy.context.object
## Note: Same owner as layer manager (will be removed as well)
def subscribe_object_change():
subscribe_to = (bpy.types.LayerObjects, 'active')
if bpy.app.version >= (5, 0, 0):
owner = bpy.types.GreasePencil
else:
owner = bpy.types.GreasePencilv3
bpy.msgbus.subscribe_rna(
key=subscribe_to,
# owner of msgbus subcribe (for clearing later)
owner=owner, # <-- attach to ID during it's lifetime.
args=(),
notify=selection_changed,
options={'PERSISTENT'},
)
@persistent
def subscribe_object_change_handler(dummy):
subscribe_object_change()
classes = (
GPTB_OT_cusor_snap,
@@ -163,14 +204,18 @@ GPTB_OT_cusor_snap,
def register():
for cls in classes:
bpy.utils.register_class(cls)
# swap_keymap_by_id('view3d.cursor3d','view3d.cursor_snap')#auto swap to custom GP snap wrap
# bpy.app.handlers.frame_change_post.append(cursor_follow)
## Follow cursor matrix update on object change
bpy.app.handlers.load_post.append(subscribe_object_change_handler) # select_change
# ## Directly set msgbus to work at first addon activation # select_change
bpy.app.timers.register(subscribe_object_change, first_interval=1) # select_change
## No need to frame_change_post.append(cursor_follow). Added by property update, when activating 'cursor follow'
def unregister():
# bpy.app.handlers.frame_change_post.remove(cursor_follow)
bpy.app.handlers.load_post.remove(subscribe_object_change_handler) # select_change
# swap_keymap_by_id('view3d.cursor_snap','view3d.cursor3d')#Restore normal snap
@@ -180,3 +225,5 @@ def unregister():
# force remove handler if it's there at unregister
if cursor_follow.__name__ in [hand.__name__ for hand in bpy.app.handlers.frame_change_post]:
bpy.app.handlers.frame_change_post.remove(cursor_follow)
bpy.msgbus.clear_by_owner(bpy.types.GreasePencil)
+173
View File
@@ -0,0 +1,173 @@
import bpy
from . import utils
from bpy_extras.view3d_utils import location_3d_to_region_2d
def is_stroke_in_view(stroke, matrix, region, region_3d):
## for optimization, use first and last
region_width = region.width
region_height = region.height
point_list = [stroke.points[0], stroke.points[-1]]
# point_list = stroke.points # whole points
coord_list = [matrix @ point.position for point in point_list]
for coord in coord_list:
# Convert 3D coordinates to 2D screen coordinates
screen_coord = location_3d_to_region_2d(region, region_3d, coord)
if screen_coord is None:
continue
# Check if the point is within the viewport bounds
if 0 <= screen_coord.x <= region_width and 0 <= screen_coord.y <= region_height:
## one point in view, in view
return True
return False
class GP_OT_delete_view_bound(bpy.types.Operator):
bl_idname = "grease_pencil.delete_view_bound"
bl_label = "Delete View Bound"
bl_description = "Delete all selected strokes / points only if there are in view (current viewport region)\
\nnote: does not work in multiframe yet)"
bl_options = {'REGISTER', 'UNDO'}
# def invoke(self, context, event):
## TODO: add scope (default to automatic)
## TODO: add option for normal delete (currenly only dissolve), probably another operator.
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_GREASE_PENCIL'
def execute(self, context):
gp = context.grease_pencil
if not gp:
self.report({'ERROR'}, "No Grease Pencil object found")
return {'CANCELLED'}
ob = context.object
if not ob or ob.type != 'GREASEPENCIL':
self.report({'ERROR'}, "Active object is not a Grease Pencil object")
return {'CANCELLED'}
select_mode = context.scene.tool_settings.gpencil_selectmode_edit
matrix_world = ob.matrix_world.copy()
## only visibile and unlocked layer in active GP object
layers = [l for l in gp.layers if not l.hide and not l.lock]
region = context.region
region_3d = context.space_data.region_3d
removed_ct = 0
avoid_ct = 0
for layer in layers:
if context.scene.tool_settings.use_grease_pencil_multi_frame_editing:
## multiframe, affect all selected frames and active frame
target_frames = [f for f in layer.frames if f.select or f == layer.current_frame()]
else:
## Only active frame
current = layer.current_frame()
if not current:
continue
target_frames = [current]
for frame in target_frames:
drawing = frame.drawing
if not drawing or not drawing.strokes:
continue
stroke_indices_to_delete = []
## List comprehention version (For stroke mode only)
# stroke_indices_to_delete = [sidx for sidx, stroke in enumerate(drawing.strokes) if stroke.select and is_stroke_in_view(stroke, matrix_world, region, region_3d)]
for sid, stroke in enumerate(drawing.strokes):
if not stroke.select:
continue
# Check if the stroke is within the view bounds
if not is_stroke_in_view(stroke, matrix_world, region, region_3d):
avoid_ct += 1
continue
## No need for further check if we are in stroke mod (direct delete)
if select_mode == 'STROKE':
stroke_indices_to_delete.append(sid)
removed_ct += 1
continue
## --- Handle points ---
## remove points, only Dissolve for now.
select_count = len([p for p in stroke.points if p.select])
if select_count == len(stroke.points):
## Mark as full delete and go next
stroke_indices_to_delete.append(sid)
continue
## Make a copy of deselected points attributes
point_attrs = []
for p in stroke.points:
if p.select:
continue
point_attrs.append([p.position.copy(), p.rotation, p.radius, p.opacity])
## remove as many points as select_count
stroke.remove_points(select_count)
## re-assign attributes in order
for i, p in enumerate(stroke.points):
if i < len(point_attrs):
p.position = point_attrs[i][0]
p.rotation = point_attrs[i][1]
p.radius = point_attrs[i][2]
p.opacity = point_attrs[i][3]
p.select = False # Deselect all points after processing
removed_ct += 1
## End of stroke loop (here either part if the stroke is deleted or marked for deletion )
if stroke_indices_to_delete:
# print(f'layer {layer.name} : delete {len(stroke_indices_to_delete)} strokes')
drawing.remove_strokes(indices=stroke_indices_to_delete)
if removed_ct and avoid_ct:
self.report({'INFO'}, f"Skipped {avoid_ct} out of view")
elif not removed_ct and not avoid_ct:
self.report({'WARNING'}, "Nothing to Delete")
elif not removed_ct and avoid_ct:
self.report({'WARNING'}, "All selected strokes are out of view")
return {'FINISHED'}
def draw_delete_view_bound_ui(self, context):
layout = self.layout
layout.operator('grease_pencil.delete_view_bound', text='Delete View bound', icon='LOCKVIEW_ON') # RESTRICT_VIEW_ON
classes = (
# GP_OT_delete_strokes_view_bound,
# GP_OT_delete_points_view_bound,
GP_OT_delete_view_bound,
)
def register():
if bpy.app.background:
return
for cl in classes:
bpy.utils.register_class(cl)
bpy.types.VIEW3D_MT_edit_greasepencil_delete.append(draw_delete_view_bound_ui)
## make scene property for empty key preservation and bake movement for layers...
# register_keymaps()
def unregister():
if bpy.app.background:
return
bpy.types.VIEW3D_MT_edit_greasepencil_delete.remove(draw_delete_view_bound_ui)
# unregister_keymaps()
for cl in reversed(classes):
bpy.utils.unregister_class(cl)
+2 -1
View File
@@ -11,6 +11,7 @@ from bpy_extras.view3d_utils import region_2d_to_location_3d, region_2d_to_vecto
location_3d_to_region_2d, region_2d_to_origin_3d, region_2d_to_location_3d
from time import time
from math import pi, cos, sin
from .utils import is_locked, is_hidden
def get_gp_mat(gp, name, set_active=False):
@@ -440,7 +441,7 @@ class GPTB_OT_eraser(Operator):
t0 = time()
gp_mats = gp.data.materials
gp_layers = [l for l in gp.data.layers if not l.lock or l.hide]
gp_layers = [l for l in gp.data.layers if not is_locked(l) or is_hidden(l)]
self.gp_frames = [l.current_frame() for l in gp_layers]
'''
points_data = [(s, f, gp_mats[s.material_index]) for f in gp_frames for s in f.drawing.strokes]
+324 -69
View File
@@ -2,32 +2,94 @@ import bpy
import os
from pathlib import Path
import numpy as np
from . import utils
def remove_stroke_exact_duplications(apply=True):
from bpy.props import (BoolProperty,
PointerProperty,
CollectionProperty,
StringProperty)
def remove_stroke_exact_duplications(apply=True, verbose=True, select=False):
'''Remove accidental stroke duplication (points exactly in the same place)
:apply: Remove the duplication instead of just listing dupes
:select: Select the duplicated strokes instead of removing them (disabled by apply)
return number of duplication found/deleted
'''
# TODO: add additional check of material (even if unlikely to happen)
# TODO: Add additional check of material (even if unlikely to happen, better to avoid false positive)
ct = 0
gp_datas = [gp for gp in bpy.data.grease_pencils]
if verbose:
print('\nRemove redundant strokes in GP frames...')
gp_source = bpy.data.grease_pencils if bpy.app.version >= (5,0,0) else bpy.data.grease_pencils_v3
gp_datas = [gp for gp in gp_source]
for gp in gp_datas:
for l in gp.layers:
for f in l.frames:
stroke_list = []
for s in reversed(f.drawing.strokes):
point_list = [p.position for p in s.points]
idx_to_delete = []
for idx, s in enumerate(f.drawing.strokes):
point_list = [p.position.copy() for p in s.points]
if point_list in stroke_list:
ct += 1
if apply:
# Remove redundancy
f.drawing.strokes.remove(s)
idx_to_delete.append(idx)
if not apply and select:
s.select = True
else:
stroke_list.append(point_list)
if not apply and select:
s.select = False
if idx_to_delete:
if verbose:
print(f"{gp.name} > {l.name} > frame {f.frame_number}: {len(idx_to_delete)} strokes")
if apply:
# Remove redundancy (carefull, passing an empty list delete all strokes)
f.drawing.remove_strokes(indices=idx_to_delete)
return ct
class GPTB_OT_remove_stroke_duplication(bpy.types.Operator):
bl_idname = "gp.remove_stroke_duplication"
bl_label = "Remove Redundant GP Stroke"
bl_description = "Within every frame, remove every strokes that are exactly superposed with a previous one"
bl_options = {"REGISTER", "UNDO"}
apply : bpy.props.BoolProperty(name="Remove Stroke", default=True,
description="Remove the duplication, else list only",
options={'SKIP_SAVE'})
select : bpy.props.BoolProperty(name="Select Duplicated Strokes", default=False)
def invoke(self, context, event):
# self.file_dump = event.ctrl
return context.window_manager.invoke_props_dialog(self) # , width=400
# return self.execute(context)
def draw(self, context):
layout=self.layout
layout.use_property_split = True
col = layout.column()
col.prop(self, 'apply')
row=col.row(align=True)
row.prop(self, 'select')
row.enabled = not self.apply
if not self.apply and not self.select:
col.label(text="Only list in console and report number of duplications", icon='INFO')
def execute(self, context):
ct = remove_stroke_exact_duplications(apply=self.apply, select=self.select, verbose=True)
if ct > 0:
if self.apply:
self.report({'INFO'}, f'Removed {ct} strokes duplications')
else:
self.report({'INFO'}, f'Found {ct} strokes duplications')
else:
self.report({'INFO'}, 'No stroke duplication found')
return {'FINISHED'}
class GPTB_OT_file_checker(bpy.types.Operator):
bl_idname = "gp.file_checker"
bl_label = "Check File"
@@ -53,6 +115,10 @@ class GPTB_OT_file_checker(bpy.types.Operator):
# Disable use light on all object
# Remove redundant strokes in frames
apply_fixes : bpy.props.BoolProperty(name="Apply Fixes", default=False,
description="Apply possible fixes instead of just listing (pop the list again in fix mode)",
options={'SKIP_SAVE'})
def invoke(self, context, event):
# need some self-control (I had to...)
self.ctrl = event.ctrl
@@ -63,10 +129,13 @@ class GPTB_OT_file_checker(bpy.types.Operator):
fix = prefs.fixprops
problems = []
apply = not fix.check_only
## Old method : Apply fixes based on pref (inverted by ctrl key)
# # If Ctrl is pressed, invert behavior (invert boolean)
# apply ^= self.ctrl
# If Ctrl is pressed, invert behavior (invert boolean)
apply ^= self.ctrl
apply = self.apply_fixes
if self.ctrl:
apply = True
## Lock main cam:
if fix.lock_main_cam:
@@ -153,6 +222,8 @@ class GPTB_OT_file_checker(bpy.types.Operator):
o.use_grease_pencil_lights = False
## Disabled animation
# TODO : fix for Blender 5.0
if bpy.app.version < (5,0,0):
if fix.list_disabled_anim:
fcu_ct = 0
for act in bpy.data.actions:
@@ -169,13 +240,14 @@ class GPTB_OT_file_checker(bpy.types.Operator):
if fix.list_obj_vis_conflict:
viz_ct = 0
for o in context.scene.objects:
if o.hide_viewport != o.hide_render:
if not (o.hide_get() == o.hide_viewport == o.hide_render):
hv = 'No' if o.hide_get() else 'Yes'
vp = 'No' if o.hide_viewport else 'Yes'
rd = 'No' if o.hide_render else 'Yes'
viz_ct += 1
print(f'{o.name} : viewport {vp} != render {rd}')
print(f'{o.name} : viewlayer {hv} - viewport {vp} - render {rd}')
if viz_ct:
problems.append(['gp.list_object_visibility', f'{viz_ct} objects visibility conflicts (details in console)', 'OBJECT_DATAMODE'])
problems.append(['gp.list_object_visibility_conflicts', f'{viz_ct} objects visibility conflicts (details in console)', 'OBJECT_DATAMODE'])
## GP modifiers visibility conflict
if fix.list_gp_mod_vis_conflict:
@@ -253,6 +325,7 @@ class GPTB_OT_file_checker(bpy.types.Operator):
bpy.context.scene.tool_settings.lock_object_mode = False
if fix.remove_redundant_strokes:
print('removing redundant strokes')
ct = remove_stroke_exact_duplications(apply=apply)
if ct > 0:
mess = f'Removed {ct} strokes duplications' if apply else f'Found {ct} strokes duplications'
@@ -281,8 +354,12 @@ class GPTB_OT_file_checker(bpy.types.Operator):
else:
print(p[0])
if not self.apply_fixes:
## button to call the operator again with apply_fixes set to True
problems.append(['OPERATOR', 'gp.file_checker', 'Apply Fixes', 'FORWARD', {'apply_fixes': True}])
# Show in viewport
title = "Changed Settings" if apply else "Checked Settings (dry run, nothing changed)"
title = "Changed Settings" if apply else "Checked Settings (nothing changed)"
utils.show_message_box(problems, _title = title, _icon = 'INFO')
else:
self.report({'INFO'}, 'All good')
@@ -482,45 +559,10 @@ class GPTB_OT_links_checker(bpy.types.Operator):
return context.window_manager.invoke_props_dialog(self, width=popup_width)
""" OLD links checker with show_message_box
class GPTB_OT_links_checker(bpy.types.Operator):
bl_idname = "gp.links_checker"
bl_label = "Links check"
bl_description = "Check states of file direct links"
bl_options = {"REGISTER"}
def execute(self, context):
all_lnks = []
has_broken_link = False
## check for broken links
for current, lib in zip(bpy.utils.blend_paths(local=True), bpy.utils.blend_paths(absolute=True, local=True)):
lfp = Path(lib)
realib = Path(current)
if not lfp.exists():
has_broken_link = True
all_lnks.append( (f"Broken link: {realib.as_posix()}", 'LIBRARY_DATA_BROKEN') )#lfp.as_posix()
else:
if realib.as_posix().startswith('//'):
all_lnks.append( (f"Link: {realib.as_posix()}", 'LINKED') )#lfp.as_posix()
else:
all_lnks.append( (f"Link: {realib.as_posix()}", 'LIBRARY_DATA_INDIRECT') )#lfp.as_posix()
all_lnks.sort(key=lambda x: x[1], reverse=True)
if all_lnks:
print('===File check===')
for p in all_lnks:
if isinstance(p, str):
print(p)
else:
print(p[0])
# Show in viewport
utils.show_message_box(all_lnks, _title = "Links", _icon = 'INFO')
return {"FINISHED"} """
class GPTB_OT_list_object_visibility(bpy.types.Operator):
bl_idname = "gp.list_object_visibility"
bl_label = "List Object Visibility Conflicts"
class GPTB_OT_list_viewport_render_visibility(bpy.types.Operator):
bl_idname = "gp.list_viewport_render_visibility"
bl_label = "List Viewport And Render Visibility Conflicts"
bl_description = "List objects visibility conflicts, when viewport and render have different values"
bl_options = {"REGISTER"}
@@ -539,20 +581,225 @@ class GPTB_OT_list_object_visibility(bpy.types.Operator):
def execute(self, context):
return {'FINISHED'}
## basic listing as message box # all in invoke now
# li = []
# viz_ct = 0
# for o in context.scene.objects:
# if o.hide_viewport != o.hide_render:
# vp = 'No' if o.hide_viewport else 'Yes'
# rd = 'No' if o.hide_render else 'Yes'
# viz_ct += 1
# li.append(f'{o.name} : viewport {vp} != render {rd}')
# if li:
# utils.show_message_box(_message=li, _title=f'{viz_ct} visibility conflicts found')
# else:
# self.report({'INFO'}, f"No Object visibility conflict on current scene")
# return {'FINISHED'}
### -- Sync visibility ops (Could be fused in one ops, but having 3 different operators allow to call from search menu)
class GPTB_OT_sync_visibility_from_viewlayer(bpy.types.Operator):
bl_idname = "gp.sync_visibility_from_viewlayer"
bl_label = "Sync Visibility From Viewlayer"
bl_description = "Set viewport and render visibility to match viewlayer visibility"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
for obj in context.scene.objects:
is_hidden = obj.hide_get() # Get viewlayer visibility
obj.hide_viewport = is_hidden
obj.hide_render = is_hidden
return {'FINISHED'}
class GPTB_OT_sync_visibility_from_viewport(bpy.types.Operator):
bl_idname = "gp.sync_visibility_from_viewport"
bl_label = "Sync Visibility From Viewport"
bl_description = "Set viewlayer and render visibility to match viewport visibility"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
for obj in context.scene.objects:
is_hidden = obj.hide_viewport
obj.hide_set(is_hidden)
obj.hide_render = is_hidden
return {'FINISHED'}
class GPTB_OT_sync_visibility_from_render(bpy.types.Operator):
bl_idname = "gp.sync_visibility_from_render"
bl_label = "Sync Visibility From Render"
bl_description = "Set viewlayer and viewport visibility to match render visibility"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
for obj in context.scene.objects:
is_hidden = obj.hide_render
obj.hide_set(is_hidden)
obj.hide_viewport = is_hidden
return {'FINISHED'}
class GPTB_OT_sync_visibible_to_render(bpy.types.Operator):
bl_idname = "gp.sync_visibible_to_render"
bl_label = "Sync Overall Viewport Visibility To Render"
bl_description = "Set render visibility from"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
for obj in context.scene.objects:
## visible_get is the current visibility status combination of hide_viewport and viewlayer hide (eye)
obj.hide_render = not obj.visible_get()
return {'FINISHED'}
class GPTB_PG_object_visibility(bpy.types.PropertyGroup):
"""Property group to handle object visibility"""
is_hidden: BoolProperty(
name="Hide in Viewport",
description="Toggle object visibility in viewport",
get=lambda self: self.get("is_hidden", False),
set=lambda self, value: self.set_visibility(value)
)
object_name: StringProperty(name="Object Name")
def set_visibility(self, value):
"""Set the visibility using hide_set()"""
obj = bpy.context.view_layer.objects.get(self.object_name)
if obj:
obj.hide_set(value)
self["is_hidden"] = value
class GPTB_OT_list_object_visibility_conflicts(bpy.types.Operator):
bl_idname = "gp.list_object_visibility_conflicts"
bl_label = "List Object Visibility Conflicts"
bl_description = "List objects visibility conflicts, when viewport and render have different values"
bl_options = {"REGISTER"}
visibility_items: CollectionProperty(type=GPTB_PG_object_visibility) # type: ignore[valid-type]
def invoke(self, context, event):
# Clear and rebuild both collections
self.visibility_items.clear()
# Store objects with conflicts
## TODO: Maybe better (but less detailed) to just check o.visible_get (global visiblity) against render viz ?
objects_with_conflicts = [o for o in context.scene.objects if not (o.hide_get() == o.hide_viewport == o.hide_render)]
# Create visibility items in same order
for obj in objects_with_conflicts:
item = self.visibility_items.add()
item.object_name = obj.name
item["is_hidden"] = obj.hide_get()
return context.window_manager.invoke_props_dialog(self, width=250)
def draw(self, context):
layout = self.layout
# Add sync buttons at the top
row = layout.row(align=False)
row.label(text="Sync All Visibility From:")
row.operator("gp.sync_visibility_from_viewlayer", text="", icon='HIDE_OFF')
row.operator("gp.sync_visibility_from_viewport", text="", icon='RESTRICT_VIEW_OFF')
row.operator("gp.sync_visibility_from_render", text="", icon='RESTRICT_RENDER_OFF')
layout.separator()
col = layout.column()
# We can safely iterate over visibility_items since objects are stored in same order
for vis_item in self.visibility_items:
obj = context.view_layer.objects.get(vis_item.object_name)
if not obj:
continue
row = col.row(align=False)
row.label(text=obj.name)
## Viewlayer visibility "as prop" to allow slide toggle
# hide_icon='HIDE_ON' if vis_item.is_hidden else 'HIDE_OFF'
hide_icon='HIDE_ON' if obj.hide_get() else 'HIDE_OFF' # based on object state
row.prop(vis_item, "is_hidden", text="", icon=hide_icon, emboss=False)
# Direct object properties
row.prop(obj, 'hide_viewport', text='', emboss=False)
row.prop(obj, 'hide_render', text='', emboss=False)
def execute(self, context):
return {'FINISHED'}
## -- List collection visibility conflicts
# class GPTB_PG_collection_visibility(bpy.types.PropertyGroup):
# """Property group to handle collection visibility"""
# is_hidden: BoolProperty(
# name="Hide in Viewport",
# description="Toggle collection visibility in viewport",
# get=lambda self: self.get("is_hidden", False),
# set=lambda self, value: self.set_visibility(value)
# )
# collection_name: StringProperty(name="Collection Name")
def get_collection_children_recursive(col, cols=None) -> list:
'''return a list of all the child collections
and their subcollections in the passed collection'''
cols = cols or []
for sub in col.children:
if sub not in cols:
cols.append(sub)
if len(sub.children):
cols = get_collection_children_recursive(sub, cols)
return cols
class GPTB_OT_list_collection_visibility_conflicts(bpy.types.Operator):
bl_idname = "gp.list_collection_visibility_conflicts"
bl_label = "List Collection Visibility Conflicts"
bl_description = "List collection visibility conflicts, when viewport and render have different values"
bl_options = {"REGISTER"}
# visibility_items: CollectionProperty(type=GPTB_PG_collection_visibility)
show_filter : bpy.props.EnumProperty(
name="View Filter",
description="Filter collections based on their exclusion status",
items=(
('ALL', "All", "Show all collections", 0),
('NOT_EXCLUDED', "Not Excluded", "Show collections that are not excluded", 1),
('EXCLUDED', "Excluded", "Show collections that are excluded", 2)
),
default='NOT_EXCLUDED')
def invoke(self, context, event):
## get all viewlayer collections
vcols = get_collection_children_recursive(context.view_layer.layer_collection)
vcols = list(set(vcols)) # ensure no duplicates
## Store collection with conflicts
# layer_collection.is_visible against render visibility ?
## Do not list currently excluded collections
self.conflict_collections = [vc for vc in vcols if not (vc.hide_viewport == vc.collection.hide_viewport == vc.collection.hide_render)]
self.included_collection = [vc for vc in self.conflict_collections if not vc.exclude]
self.excluded_collection = [vc for vc in self.conflict_collections if vc.exclude]
return context.window_manager.invoke_props_dialog(self, width=250)
def draw(self, context):
layout = self.layout
layout.prop(self, 'show_filter', expand=True)
# Add sync buttons at the top
row = layout.row(align=False)
# TODO: Add "set all from" ops on collection (optionnal)
# row.label(text="Sync All Visibility From:")
# row.operator("gp.sync_visibility_from_viewlayer", text="", icon='HIDE_OFF')
# row.operator("gp.sync_visibility_from_viewport", text="", icon='RESTRICT_VIEW_OFF')
# row.operator("gp.sync_visibility_from_render", text="", icon='RESTRICT_RENDER_OFF')
layout.separator()
if self.show_filter == 'ALL':
vl_collections = self.conflict_collections
elif self.show_filter == 'EXCLUDED':
vl_collections = self.excluded_collection
elif self.show_filter == 'NOT_EXCLUDED':
vl_collections = self.included_collection
col = layout.column()
for vlcol in vl_collections:
row = col.row(align=False)
row.label(text=vlcol.name)
# Viewlayer collection settings
row.prop(vlcol, "exclude", text="", emboss=False)
row.prop(vlcol, "hide_viewport", text="", emboss=False)
# Direct collection properties
row.prop(vlcol.collection, 'hide_viewport', text='', emboss=False)
row.prop(vlcol.collection, 'hide_render', text='', emboss=False)
def execute(self, context):
return {'FINISHED'}
## not exposed in UI, Check is performed in Check file (can be called in popped menu)
class GPTB_OT_list_modifier_visibility(bpy.types.Operator):
@@ -594,12 +841,20 @@ class GPTB_OT_list_modifier_visibility(bpy.types.Operator):
return {'FINISHED'}
classes = (
GPTB_OT_list_object_visibility,
GPTB_OT_list_viewport_render_visibility, # Only viewport and render
GPTB_OT_sync_visibility_from_viewlayer,
GPTB_OT_sync_visibility_from_viewport,
GPTB_OT_sync_visibility_from_render,
GPTB_OT_sync_visibible_to_render,
GPTB_PG_object_visibility,
GPTB_OT_list_object_visibility_conflicts,
GPTB_OT_list_collection_visibility_conflicts,
GPTB_OT_list_modifier_visibility,
GPTB_OT_copy_string_to_clipboard,
GPTB_OT_copy_multipath_clipboard,
GPTB_OT_file_checker,
GPTB_OT_links_checker,
GPTB_OT_remove_stroke_duplication,
)
def register():
+2 -2
View File
@@ -7,12 +7,12 @@ import numpy as np
from time import time
from .utils import (location_to_region, region_to_location)
## DISABLED (in init, also in menu append, see register below)
"""
## Do not work on multiple object
def batch_flat_reproject(obj, proj_type='VIEW', all_strokes=True, restore_frame=False):
'''Reproject
:all_stroke: affect hided, locked layers
:all_stroke: affect hidden, locked layers
'''
if restore_frame:
-1
View File
@@ -2,7 +2,6 @@ import bpy
from mathutils import Vector
from . import utils
class GPTB_OT_create_follow_path_curve(bpy.types.Operator):
bl_idname = "object.create_follow_path_curve"
bl_label = "Create Follow Path Curve"
+28 -31
View File
@@ -212,9 +212,14 @@ class GPTB_OT_draw_cam(Operator):
# Swap to it, unhide if necessary and hide previous
context.scene.camera = maincam
## hide cam object
drawcam.hide_viewport = True
## Hide cam object
# Ensure at lviewport viz is active to ensure visibility and refresh state
maincam.hide_viewport = False
# drawcam.hide_viewport = False # Not absolutely needed
drawcam.hide_set(True)
maincam.hide_set(False)
## if in main camera GO to drawcam
elif context.scene.camera.name not in ('draw_cam', 'obj_cam'):
@@ -267,8 +272,12 @@ class GPTB_OT_draw_cam(Operator):
## hide cam object
context.scene.camera = drawcam
# Ensure viewport viz is active to ensure visibility and refresh state
drawcam.hide_viewport = False
maincam.hide_viewport = True
maincam.hide_viewport = False
## Set viewlayer visibility
drawcam.hide_set(False)
maincam.hide_set(True)
if created and drawcam.name == 'obj_cam': # Go in camera view
context.region_data.view_perspective = 'CAMERA'
@@ -412,12 +421,12 @@ class GPTB_OT_toggle_mute_animation(Operator):
self.selection = event.shift
return self.execute(context)
def set_action_mute(self, act):
for i, fcu in enumerate(act.fcurves):
def set_action_mute(self, bag):
for i, fcu in enumerate(bag.fcurves):
print(i, fcu.data_path, fcu.array_index)
# fcu.group don't have mute attribute in api.
fcu.mute = self.mute
for g in act.groups:
for g in bag.groups:
g.mute = self.mute
def execute(self, context):
@@ -434,22 +443,15 @@ class GPTB_OT_toggle_mute_animation(Operator):
if self.mode == 'CAMERA' and o.type != 'CAMERA':
continue
# mute attribute animation for GP and cameras
## Mute attribute animation for GP and cameras
if o.type in ('GREASEPENCIL', 'CAMERA') and o.data.animation_data:
gp_act = o.data.animation_data.action
if gp_act:
if data_channelbag := utils.get_active_channelbag(o.data):
print(f'\n---{o.name} data:')
self.set_action_mute(gp_act)
if not o.animation_data:
continue
act = o.animation_data.action
if not act:
continue
self.set_action_mute(data_channelbag)
if object_channelbag := utils.get_active_channelbag(o):
print(f'\n---{o.name}:')
self.set_action_mute(act)
self.set_action_mute(object_channelbag)
return {'FINISHED'}
@@ -475,7 +477,7 @@ class GPTB_OT_toggle_hide_gp_modifier(Operator):
for o in pool:
if o.type != 'GREASEPENCIL':
continue
for m in o.modifier:
for m in o.modifiers:
# skip modifier that are not visible in render
if not m.show_render:
continue
@@ -486,7 +488,7 @@ class GPTB_OT_toggle_hide_gp_modifier(Operator):
class GPTB_OT_list_disabled_anims(Operator):
bl_idname = "gp.list_disabled_anims"
bl_label = "List Disabled Anims"
bl_description = "List disabled animations channels in scene. (shit+clic to list only on seleciton)"
bl_description = "List disabled animations channels in scene. (shit+clic to list only on selection)"
bl_options = {"REGISTER"}
mute : bpy.props.BoolProperty(default=False)
@@ -512,27 +514,22 @@ class GPTB_OT_list_disabled_anims(Operator):
# continue
if o.type == 'GREASEPENCIL':
if o.data.animation_data:
gp_act = o.data.animation_data.action
if gp_act:
for i, fcu in enumerate(gp_act.fcurves):
if fcurves := utils.get_fcurves(o.data):
for i, fcu in enumerate(fcurves):
if fcu.mute:
if o not in oblist:
oblist.append(o)
li.append(f'{o.name}:')
li.append(f' - {fcu.data_path} {fcu.array_index}')
if not o.animation_data:
bag = utils.get_active_channelbag(o)
if not bag:
continue
act = o.animation_data.action
if not act:
continue
for g in act.groups:
for g in bag.groups:
if g.mute:
li.append(f'{o.name} - group: {g.name}')
for i, fcu in enumerate(act.fcurves):
for i, fcu in enumerate(bag.fcurves):
# print(i, fcu.data_path, fcu.array_index)
if fcu.mute:
if o not in oblist:
+10 -10
View File
@@ -1,5 +1,6 @@
import bpy
from bpy.types import Operator
from . import utils
def get_layer_list(self, context):
@@ -41,7 +42,7 @@ class GPTB_OT_duplicate_send_to_layer(Operator) :
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'GREASEPENCIL'\
and context.space_data.bl_rna.identifier == 'SpaceDopeSheetEditor' and context.space_data.ui_mode == 'GREASEPENCIL'
and context.space_data.bl_rna.identifier == 'SpaceDopeSheetEditor' and context.space_data.ui_mode == 'GPENCIL'
# history : bpy.props.StringProperty(default='', options={'SKIP_SAVE'}) # need to have a variable to store (to get it in self)
@@ -65,26 +66,25 @@ class GPTB_OT_duplicate_send_to_layer(Operator) :
## Remove overlapping frames
for f in reversed(to_replace):
target_layer.frames.remove(f)
target_layer.frames.remove(f.frame_number)
## Copy original frames
for f in selected_frames:
target_layer.frames.copy(f)
utils.copy_frame_at(f, target_layer, f.frame_number)
# target_layer.frames.copy(f) # GPv2
sent = len(selected_frames)
## Delete original frames as an option
if self.delete_source:
for f in reversed(selected_frames):
act_layer.frames.remove(f)
act_layer.frames.remove(f.frame_number)
mess = f'{sent} keys moved'
else:
mess = f'{sent} keys copied'
if replaced:
mess += f' ({replaced} replaced)'
mod = context.mode
bpy.ops.gpencil.editmode_toggle()
bpy.ops.object.mode_set(mode=mod)
self.report({'INFO'}, mess)
return {'FINISHED'}
@@ -131,7 +131,7 @@ def unregister_keymaps():
def menu_duplicate_and_send_to_layer(self, context):
if context.space_data.ui_mode == 'GREASEPENCIL':
if context.space_data.ui_mode == 'GPENCIL':
self.layout.operator_context = 'INVOKE_REGION_WIN'
self.layout.operator('gp.duplicate_send_to_layer', text='Move Keys To Layer').delete_source = True
self.layout.operator('gp.duplicate_send_to_layer', text='Copy Keys To Layer')
+5 -4
View File
@@ -1,5 +1,5 @@
import bpy
from .utils import get_addon_prefs
from .utils import get_addon_prefs, is_locked, is_hidden
from bpy.props import BoolProperty ,EnumProperty ,StringProperty
class GPTB_OT_jump_gp_keyframe(bpy.types.Operator):
@@ -36,6 +36,7 @@ class GPTB_OT_jump_gp_keyframe(bpy.types.Operator):
('MOVING_HOLD', 'Moving Hold', '', 'KEYTYPE_MOVING_HOLD_VEC', 4),
('EXTREME', 'Extreme', '', 'KEYTYPE_EXTREME_VEC', 5),
('JITTER', 'Jitter', '', 'KEYTYPE_JITTER_VEC', 6),
('GENERATED', 'Generated', '', 'KEYTYPE_GENERATED_VEC', 7),
))
def execute(self, context):
@@ -44,15 +45,15 @@ class GPTB_OT_jump_gp_keyframe(bpy.types.Operator):
return {"CANCELLED"}
if self.target == 'ACTIVE':
gpl = [l for l in context.object.data.layers if l.select and not l.hide]
gpl = [l for l in context.object.data.layers if l.select and not is_hidden(l)]
if not context.object.data.layers.active in gpl:
gpl.append(context.object.data.layers.active)
elif self.target == 'VISIBLE':
gpl = [l for l in context.object.data.layers if not l.hide]
gpl = [l for l in context.object.data.layers if not is_hidden(l)]
elif self.target == 'ACCESSIBLE':
gpl = [l for l in context.object.data.layers if not l.hide and not l.lock]
gpl = [l for l in context.object.data.layers if not is_hidden(l) and not is_locked(l)]
if self.keyframe_type != 'NONE':
# use shortcut choice override
+35 -85
View File
@@ -21,6 +21,7 @@ from .utils import get_addon_prefs, is_vector_close
# PATTERN = r'^(?P<grp>-\s)?(?P<tag>[A-Z]{2}_)?(?P<tag2>[A-Z]{1,6}_)?(?P<name>.*?)(?P<sfix>_[A-Z]{2})?(?P<inc>\.\d{3})?$' # numering
PATTERN = r'^(?P<grp>-\s)?(?P<tag>[A-Z]{2}_)?(?P<name>.*?)(?P<sfix>_[A-Z]{2})?(?P<inc>\.\d{3})?$' # numering
# TODO: allow a more flexible prefix pattern
def layer_name_build(layer, prefix='', desc='', suffix=''):
'''GET a layer and argument to build and assign name
@@ -78,7 +79,7 @@ def layer_name_build(layer, prefix='', desc='', suffix=''):
# maybe a more elegant way exists to find all objects users ?
# update Gpencil modifier targets
for mod in ob_user.modifier:
for mod in ob_user.modifiers:
if not hasattr(mod, 'layer_filter'):
continue
if mod.layer_filter == old:
@@ -155,13 +156,16 @@ class GPTB_OT_layer_name_build(Operator):
gpl = ob.data.layers
act = gpl.active
if not act:
self.report({'ERROR'}, 'no layer active')
act = ob.data.layer_groups.active
if not act:
self.report({'ERROR'}, 'No layer active')
return {"CANCELLED"}
layer_name_build(act, prefix=self.prefix, desc=self.desc, suffix=self.suffix)
## Deactivate multi-selection on layer !
## somethimes it affect a random layer that is still considered selected
## /!\ Deactivate multi-selection on layer !
## Somethimes it affect a random layer that is still considered selected
# for l in gpl:
# if l.select or l == act:
# layer_name_build(l, prefix=self.prefix, desc=self.desc, suffix=self.suffix)
@@ -169,79 +173,6 @@ class GPTB_OT_layer_name_build(Operator):
return {"FINISHED"}
def grp_toggle(l, mode='TOGGLE'):
'''take mode in (TOGGLE, GROUP, UNGROUP) '''
grp_item_id = ' - '
res = re.search(r'^(\s{1,3}-\s{0,3})(.*)', l.name)
if not res and mode in ('TOGGLE', 'GROUP'):
# No gpr : add group prefix after stripping all space and dash
l.name = grp_item_id + l.name.lstrip(' -')
elif res and mode in ('TOGGLE', 'UNGROUP'):
# found : delete group prefix
l.name = res.group(2)
class GPTB_OT_layer_group_toggle(Operator):
bl_idname = "gp.layer_group_toggle"
bl_label = "Group Toggle"
bl_description = "Group or ungroup a layer"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
# group : StringProperty(default='', options={'SKIP_SAVE'})
def execute(self, context):
ob = context.object
gpl = ob.data.layers
act = gpl.active
if not act:
self.report({'ERROR'}, 'no layer active')
return {"CANCELLED"}
for l in gpl:
if l.select or l == act:
grp_toggle(l)
return {"FINISHED"}
class GPTB_OT_layer_new_group(Operator):
bl_idname = "gp.layer_new_group"
bl_label = "New Group"
bl_description = "Create a group from active layer"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
ob = context.object
gpl = ob.data.layers
act = gpl.active
if not act:
self.report({'ERROR'}, 'no layer active')
return {"CANCELLED"}
res = re.search(PATTERN, act.name)
if not res:
self.report({'ERROR'}, 'Could not create a group name, create a layer manually')
return {"CANCELLED"}
name = res.group('name').strip(' -')
if not name:
self.report({'ERROR'}, f'No name found in {act.name}')
return {"CANCELLED"}
if name in [l.name.strip(' -') for l in gpl]:
self.report({'WARNING'}, f'Name already exists: {act.name}')
return {"FINISHED"}
grp_toggle(act, mode='GROUP')
n = gpl.new(name, set_active=False)
n.use_onion_skinning = n.use_lights = False
n.hide = True
n.opacity = 0
return {"FINISHED"}
#-## SELECTION MANAGEMENT ##-#
def activate_channel_group_color(context):
@@ -646,6 +577,7 @@ def gpencil_dopesheet_header(self, context):
def gpencil_layer_dropdown_menu(self, context):
'''to append in GPENCIL_MT_layer_context_menu'''
self.layout.operator('gp.create_empty_frames', icon='KEYFRAME')
self.layout.operator('gp.rename_gp_layers', icon='BORDERMOVE')
## handler and msgbus
@@ -675,14 +607,32 @@ def obj_layer_name_callback():
# print('inc:', res.group('inc'))
bpy.context.scene.gptoolprops['layer_name'] = res.group('name')
## old gpv2
# def subscribe_layer_change():
# subscribe_to = (bpy.types.GreasePencilLayers, "active_index")
# bpy.msgbus.subscribe_rna(
# key=subscribe_to,
# # owner of msgbus subcribe (for clearing later)
# # owner=handle,
# owner=bpy.types.GreasePencil, # <-- can attach to an ID during all it's lifetime...
# # Args passed to callback function (tuple)
# args=(),
# # Callback function for property update
# notify=obj_layer_name_callback,
# options={'PERSISTENT'},
# )
def subscribe_layer_change():
subscribe_to = (bpy.types.GreasePencilLayers, "active_index")
subscribe_to = (bpy.types.GreasePencilv3Layers, "active")
if bpy.app.version >= (5, 0, 0):
owner = bpy.types.GreasePencil
else:
owner = bpy.types.GreasePencilv3
bpy.msgbus.subscribe_rna(
key=subscribe_to,
# owner of msgbus subcribe (for clearing later)
# owner=handle,
owner=bpy.types.GreasePencil, # <-- can attach to an ID during all it's lifetime...
owner=owner, # <-- can attach to an ID during all it's lifetime...
# Args passed to callback function (tuple)
args=(),
# Callback function for property update
@@ -690,6 +640,7 @@ def subscribe_layer_change():
options={'PERSISTENT'},
)
@persistent
def subscribe_layer_change_handler(dummy):
subscribe_layer_change()
@@ -723,7 +674,7 @@ class GPTB_PT_layer_name_ui(bpy.types.Panel):
row = layout.row()
row.activate_init = True
row.label(icon='OUTLINER_DATA_GP_LAYER')
row.prop(context.object.data.layers.active, 'info', text='')
row.prop(context.object.data.layers.active, 'name', text='')
def add_layer(context):
bpy.ops.gpencil.layer_add()
@@ -802,8 +753,6 @@ def unregister_keymaps():
classes=(
GPTB_OT_rename_gp_layer,
GPTB_OT_layer_name_build,
GPTB_OT_layer_group_toggle,
GPTB_OT_layer_new_group,
GPTB_OT_select_set_same_prefix,
GPTB_OT_select_set_same_color,
@@ -819,7 +768,7 @@ def register():
bpy.types.DATA_PT_grease_pencil_layers.prepend(layer_name_builder_ui)
bpy.types.DOPESHEET_HT_header.append(gpencil_dopesheet_header)
bpy.types.GPENCIL_MT_layer_context_menu.append(gpencil_layer_dropdown_menu)
bpy.types.GREASE_PENCIL_MT_grease_pencil_add_layer_extra.append(gpencil_layer_dropdown_menu)
bpy.app.handlers.load_post.append(subscribe_layer_change_handler)
register_keymaps()
@@ -829,12 +778,13 @@ def register():
def unregister():
unregister_keymaps()
bpy.app.handlers.load_post.remove(subscribe_layer_change_handler)
bpy.types.GPENCIL_MT_layer_context_menu.remove(gpencil_layer_dropdown_menu)
bpy.types.GREASE_PENCIL_MT_grease_pencil_add_layer_extra.remove(gpencil_layer_dropdown_menu)
bpy.types.DOPESHEET_HT_header.remove(gpencil_dopesheet_header)
bpy.types.DATA_PT_grease_pencil_layers.remove(layer_name_builder_ui)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
# delete layer index trigger
# Delete layer index trigger
# /!\ can remove msgbus made for other functions or other addons using same owner
bpy.msgbus.clear_by_owner(bpy.types.GreasePencil)
+5 -5
View File
@@ -26,10 +26,11 @@ class GPT_OT_layer_nav(bpy.types.Operator):
prefs = utils.get_addon_prefs()
if not prefs.nav_use_fade:
if self.direction == 'DOWN':
utils.iterate_selector(context.object.data.layers, 'active_index', -1, info_attr = 'info')
utils.iterate_active_layer(context.grease_pencil, -1)
# utils.iterate_selector(context.object.data.layers, 'active_index', -1, info_attr = 'name') # gpv2
if self.direction == 'UP':
utils.iterate_selector(context.object.data.layers, 'active_index', 1, info_attr = 'info')
utils.iterate_active_layer(context.grease_pencil, 1)
return {'FINISHED'}
## get up and down keys for use in modal
@@ -91,12 +92,11 @@ class GPT_OT_layer_nav(bpy.types.Operator):
context.space_data.overlay.gpencil_fade_layer = fade
if self.direction == 'DOWN' or ((event.type in self.down_keys) and event.value == 'PRESS'):
_val = utils.iterate_selector(context.object.data.layers, 'active_index', -1, info_attr = 'info')
_val = utils.iterate_active_layer(context.grease_pencil, -1)
trigger = True
if self.direction == 'UP' or ((event.type in self.up_keys) and event.value == 'PRESS'):
_val = utils.iterate_selector(context.object.data.layers, 'active_index', 1, info_attr = 'info')
# utils.iterate_selector(bpy.context.scene.grease_pencil.layers, 'active_index', 1, info_attr = 'info')#layers
_val = utils.iterate_active_layer(context.grease_pencil, 1)
trigger = True
if trigger:
+16 -16
View File
@@ -4,7 +4,7 @@ import mathutils
from mathutils import Vector, Matrix, geometry
from bpy_extras import view3d_utils
from time import time
from .utils import get_gp_draw_plane, location_to_region, region_to_location
from .utils import get_gp_draw_plane, location_to_region, region_to_location, is_locked, is_hidden
class GP_OT_pick_closest_layer(Operator):
bl_idname = "gp.pick_closest_layer"
@@ -33,8 +33,8 @@ class GP_OT_pick_closest_layer(Operator):
mouse_vec3 = Vector((*self.init_mouse, 0))
co, index, _dist = kd.find(mouse_vec3)
lid = self.point_pair[index][1]
return lid
layer = self.point_pair[index][1]
return layer
def invoke(self, context, event):
self.t0 = time()
@@ -75,10 +75,10 @@ class GP_OT_pick_closest_layer(Operator):
self.inv_mat = self.ob.matrix_world.inverted()
self.point_pair = []
if context.scene.tool_settings.use_grease_pencil_multi_frame_editing:
for layer_id, l in enumerate(gp.layers):
if l.hide: # l.lock or
for layer in gp.layers:
if is_hidden(layer):
continue
for f in l.frames:
for f in layer.frames:
if not f.select:
continue
for s in f.drawing.strokes:
@@ -86,33 +86,33 @@ class GP_OT_pick_closest_layer(Operator):
continue
elif self.stroke_filter == 'FILL' and not self.ob.data.materials[s.material_index].grease_pencil.show_fill:
continue
self.point_pair += [(Vector((*location_to_region(mat @ p.position), 0)), layer_id) for p in s.points]
self.point_pair += [(Vector((*location_to_region(mat @ p.position), 0)), layer) for p in s.points]
else:
# [s for l in gp.layers if not l.lock and not l.hide for s in l.current_frame().stokes]
for layer_id, l in enumerate(gp.layers):
if l.hide or not l.current_frame(): # l.lock or
# [s for l in gp.layers if not is_locked(l) and not is_hidden(l) for s in l.current_frame().stokes]
for layer in gp.layers:
if is_hidden(layer) or not layer.current_frame():
continue
for s in l.current_frame().drawing.strokes:
for s in layer.current_frame().drawing.strokes:
if self.stroke_filter == 'STROKE' and not self.ob.data.materials[s.material_index].grease_pencil.show_stroke:
continue
elif self.stroke_filter == 'FILL' and not self.ob.data.materials[s.material_index].grease_pencil.show_fill:
continue
self.point_pair += [(Vector((*location_to_region(mat @ p.position), 0)), layer_id) for p in s.points]
self.point_pair += [(Vector((*location_to_region(mat @ p.position), 0)), layer) for p in s.points]
if not self.point_pair:
self.report({'ERROR'}, 'No stroke found, maybe layers are locked or hidden')
return {'CANCELLED'}
lid = self.filter_stroke(context)
if isinstance(lid, str):
self.report({'ERROR'}, lid)
layer_target = self.filter_stroke(context)
if isinstance(layer_target, str):
self.report({'ERROR'}, layer_target)
return {'CANCELLED'}
del self.point_pair # auto garbage collected ?
self.ob.data.layers.active_index = lid
self.ob.data.layers.active = layer_target
## debug show trigger time
# print(f'Trigger time {time() - self.t0:.3f}')
+16 -16
View File
@@ -93,6 +93,7 @@ class GPTB_OT_move_material_to_layer(Operator) :
# t = time.time() # Dbg
total = 0
oct = 0
for ob in pool:
mat_index = next((i for i, ms in enumerate(ob.material_slots) if ms.material and ms.material == mat), None)
if mat_index is None:
@@ -110,26 +111,26 @@ class GPTB_OT_move_material_to_layer(Operator) :
### Move Strokes to a new key (or existing key if comming for yet another layer)
fct = 0
sct = 0
for l in gpl:
if l == target_layer:
for layer in gpl:
if layer == target_layer:
## ! infinite loop if target layer is included
continue
for f in l.frames:
for fr in layer.frames:
## skip if no stroke has active material
if not next((s for s in f.drawing.strokes if s.material_index == mat_index), None):
if not next((s for s in fr.drawing.strokes if s.material_index == mat_index), None):
continue
## Get/Create a destination frame and keep a reference to it
if not (dest_key := key_dict.get(f.frame_number)):
dest_key = target_layer.frames.new(f.frame_number)
if not (dest_key := key_dict.get(fr.frame_number)):
dest_key = target_layer.frames.new(fr.frame_number)
key_dict[dest_key.frame_number] = dest_key
print(f'{ob.name} : frame {f.frame_number}')
print(f'{ob.name} : frame {fr.frame_number}')
## Replicate strokes in dest_keys
stroke_to_delete = []
for s in f.drawing.strokes:
for s_idx, s in enumerate(fr.drawing.strokes):
if s.material_index == mat_index:
utils.copy_stroke_to_frame(s, dest_key)
stroke_to_delete.append(s)
stroke_to_delete.append(s_idx)
## Debug
# if time.time() - t > 10:
@@ -138,16 +139,15 @@ class GPTB_OT_move_material_to_layer(Operator) :
sct += len(stroke_to_delete)
# print('Removing frames') # Dbg
## Remove from source frame (f)
## Remove from source frame (fr)
if not self.copy:
for s in reversed(stroke_to_delete):
f.drawing.strokes.remove(s)
# print('Removing frames') # Dbg
if stroke_to_delete:
fr.drawing.remove_strokes(indices=stroke_to_delete)
## ? Remove frame if layer is empty ? -> probably not, will show previous frame
## ? Remove frame if layer is empty ? -> probably not, otherwise will show previous frame
fct += 1
l.frames.update()
if fct:
@@ -165,7 +165,7 @@ class GPTB_OT_move_material_to_layer(Operator) :
# def menu_duplicate_and_send_to_layer(self, context):
# if context.space_data.ui_mode == 'GREASEPENCIL':
# if context.space_data.ui_mode == 'GPENCIL':
# self.layout.operator_context = 'INVOKE_REGION_WIN'
# self.layout.operator('gp.duplicate_send_to_layer', text='Move Keys To Layer').delete_source = True
# self.layout.operator('gp.duplicate_send_to_layer', text='Copy Keys To Layer')
+13 -9
View File
@@ -4,7 +4,12 @@ import mathutils
from mathutils import Vector, Matrix, geometry
from bpy_extras import view3d_utils
from time import time
from .utils import get_gp_draw_plane, location_to_region, region_to_location
from .utils import (get_gp_draw_plane,
location_to_region,
region_to_location,
is_locked,
is_hidden)
### passing by 2D projection
def get_3d_coord_on_drawing_plane_from_2d(context, co):
@@ -79,7 +84,7 @@ class GP_OT_pick_closest_material(Operator):
if context.scene.tool_settings.use_grease_pencil_multi_frame_editing:
for l in self.gp.layers:
if l.hide:# l.lock or
if is_hidden(l):# is_locked(l) or
continue
for f in l.frames:
if not f.select:
@@ -88,9 +93,9 @@ class GP_OT_pick_closest_material(Operator):
self.stroke_list.append(s)
else:
# [s for l in self.gp.layers if not l.lock and not l.hide for s in l.current_frame().stokes]
# [s for l in self.gp.layers if not is_locked(l) and not is_hidden(l) for s in l.current_frame().stokes]
for l in self.gp.layers:
if l.hide or not l.current_frame():# l.lock or
if is_hidden(l) or not l.current_frame():# is_locked(l) or
continue
for s in l.current_frame().drawing.strokes:
self.stroke_list.append(s)
@@ -235,7 +240,7 @@ class GP_OT_pick_closest_material(Operator):
if context.scene.tool_settings.use_grease_pencil_multi_frame_editing:
for l in gp.layers:
if l.hide:# l.lock or
if is_hidden(l):# is_locked(l) or
continue
for f in l.frames:
if not f.select:
@@ -244,9 +249,9 @@ class GP_OT_pick_closest_material(Operator):
self.stroke_list.append(s)
else:
# [s for l in gp.layers if not l.lock and not l.hide for s in l.current_frame().stokes]
# [s for l in gp.layers if not is_locked(l) and not is_hidden(l) for s in l.current_frame().stokes]
for l in gp.layers:
if l.hide or not l.current_frame():# l.lock or
if is_hidden(l) or not l.current_frame():# is_locked(l) or
continue
for s in l.current_frame().drawing.strokes:
self.stroke_list.append(s)
@@ -290,9 +295,8 @@ class GP_OT_pick_closest_material(Operator):
addon_keymaps = []
def register_keymaps():
addon = bpy.context.window_manager.keyconfigs.addon
# km = addon.keymaps.new(name = "Grease Pencil Stroke Paint (Draw brush)", space_type = "EMPTY", region_type='WINDOW')
# km = addon.keymaps.new(name = "Grease Pencil Paint Mode", space_type = "EMPTY", region_type='WINDOW')
km = addon.keymaps.new(name = "Grease Pencil Stroke Paint (Fill)", space_type = "EMPTY", region_type='WINDOW')
km = addon.keymaps.new(name = "Grease Pencil Fill Tool", space_type = "EMPTY", region_type='WINDOW')
kmi = km.keymap_items.new(
# name="",
idname="gp.pick_closest_material",
+2 -2
View File
@@ -165,9 +165,9 @@ class GPTB_OT_import_obj_palette(Operator):
# self.report({'WARNING'}, 'All materials are already in other selected object')
# unlink objects and their gp data
data_source = bpy.data.grease_pencils if bpy.app.version >= (5, 0, 0) else bpy.data.grease_pencils_v3
for src_ob in linked_objs:
bpy.data.grease_pencils.remove(src_ob.data)
data_source.remove(src_ob.data)
return {"FINISHED"}
+47 -71
View File
@@ -1,11 +1,13 @@
import bpy
import mathutils
import numpy as np
from mathutils import Matrix, Vector
from math import pi
import numpy as np
from time import time
from . import utils
from mathutils.geometry import intersect_line_plane
from . import utils
from .utils import is_hidden, is_locked
def get_scale_matrix(scale):
# recreate a neutral mat scale
@@ -15,38 +17,9 @@ def get_scale_matrix(scale):
matscale = matscale_x @ matscale_y @ matscale_z
return matscale
'''
## Old reproject method using Operators:
omode = bpy.context.mode
if all_strokes:
layers_state = [[l, l.hide, l.lock, l.lock_frame] for l in obj.data.layers]
for l in obj.data.layers:
l.hide = False
l.lock = False
l.lock_frame = False
bpy.ops.object.mode_set(mode='EDIT')
for fnum in frame_list:
bpy.context.scene.frame_current = fnum
bpy.ops.gpencil.select_all(action='SELECT')
bpy.ops.gpencil.reproject(type=proj_type) # 'INVOKE_DEFAULT'
bpy.ops.gpencil.select_all(action='DESELECT')
# restore
if all_strokes:
for layer, hide, lock, lock_frame in layers_state:
layer.hide = hide
layer.lock = lock
layer.lock_frame = lock_frame
bpy.ops.object.mode_set(mode=omode)
'''
def batch_reproject(obj, proj_type='VIEW', all_strokes=True, restore_frame=False):
'''Reproject - ops method
:all_stroke: affect hided, locked layers
:all_stroke: affect hidden, locked layers
'''
if restore_frame:
@@ -69,16 +42,23 @@ def batch_reproject(obj, proj_type='VIEW', all_strokes=True, restore_frame=False
# matrix = np.array(obj.matrix_world, dtype='float64')
# matrix_inv = np.array(obj.matrix_world.inverted(), dtype='float64')
#mat = src.matrix_world
for l in obj.data.layers:
for layer in obj.data.layers:
if not all_strokes:
if not l.select:
if not layer.select:
continue
if l.hide or l.lock:
if is_hidden(layer) or is_locked(layer):
continue
f = next((f for f in l.frames if f.frame_number == i), None)
if f is None:
frame = next((f for f in layer.frames if f.frame_number == i), None)
if frame is None:
print(layer.name, 'Not found')
# FIXME: some strokes are ignored
# print(frame'skip {layer.name}, no frame at {i}')
continue
for s in f.drawing.strokes:
for s in frame.drawing.strokes:
# print(layer.name, s.material_index)
## Batch matrix apply (Here is slower than list comprehension).
# nb_points = len(s.points)
# coords = np.empty(nb_points * 3, dtype='float64')
@@ -90,17 +70,16 @@ def batch_reproject(obj, proj_type='VIEW', all_strokes=True, restore_frame=False
new_world_co_3d = [intersect_line_plane(origin, p, plan_co, plane_no) for p in world_co_3d]
## Basic method (Slower than foreach_set)
# for i, p in enumerate(s.points):
# p.position = obj.matrix_world.inverted() @ new_world_co_3d[i]
# Basic method (Slower than foreach_set and compatible with GPv3)
## TODO: use low level api with curve offsets...
for pt_index, point in enumerate(s.points):
point.position = matrix_inv @ new_world_co_3d[pt_index]
## GPv2: ravel and use foreach_set
## Ravel new coordinate on the fly
new_local_coords = [axis for p in new_world_co_3d for axis in matrix_inv @ p]
## Set points in obj local space (apply matrix slower)
# new_local_coords = utils.matrix_transform(new_world_co_3d, matrix_inv).ravel()
s.points.foreach_set('co', new_local_coords)
## NOTE: Set points in obj local space (apply matrix is slower): new_local_coords = utils.matrix_transform(new_world_co_3d, matrix_inv).ravel()
# new_local_coords = [axis for p in new_world_co_3d for axis in matrix_inv @ p]
# s.points.foreach_set('co', new_local_coords)
if restore_frame:
bpy.context.scene.frame_current = oframe
@@ -142,21 +121,21 @@ def align_global(reproject=True, ref=None, all_strokes=True):
for s in f.drawing.strokes:
## foreach
coords = [p.position @ mat.inverted() @ new_mat for p in s.points]
# print('coords: ', coords)
# print([co for v in coords for co in v])
s.points.foreach_set('co', [co for v in coords for co in v])
# s.points.update() # seem to works # but adding/deleting a point is "safer"
## force update
s.points.add(1)
s.points.pop()
# for p in s.points:
## GPv2
# s.points.foreach_set('co', [co for v in coords for co in v])
# # s.points.update() # seem to works # but adding/deleting a point is "safer"
# ## force update
# s.points.add(1)
# s.points.pop()
for p in s.points:
## GOOD :
# world_co = mat @ p.position
# p.position = new_mat.inverted() @ world_co
## GOOD :
# p.position = p.position @ mat.inverted() @ new_mat
p.position = p.position @ mat.inverted() @ new_mat
if o.parent:
o.matrix_world = new_mat
@@ -179,10 +158,10 @@ def align_all_frames(reproject=True, ref=None, all_strokes=True):
chanel = 'rotation_quaternion' if o.rotation_mode == 'QUATERNION' else 'rotation_euler'
## double list keys
rot_keys = [int(k.co.x) for fcu in o.animation_data.action.fcurves for k in fcu.keyframe_points if fcu.data_path == chanel]
rot_keys = [int(k.co.x) for fcu in utils.get_fcurves(o) for k in fcu.keyframe_points if fcu.data_path == chanel]
## normal iter
# for fcu in o.animation_data.action.fcurves:
# for fcu in utils.get_fcurves(o):
# if fcu.data_path != chanel :
# continue
# for k in fcu.keyframe_points():
@@ -289,12 +268,12 @@ class GPTB_OT_realign(bpy.types.Operator):
self.alert = ''
o = context.object
if o.animation_data and o.animation_data.action:
act = o.animation_data.action
fcurves = utils.get_fcurves(o)
if fcurves:
for chan in ('rotation_euler', 'rotation_quaternion'):
if act.fcurves.find(chan):
if fcurves.find(chan):
self.alert = 'Animated Rotation (CONSTANT interpolation)'
interpos = [p for fcu in act.fcurves if fcu.data_path == chan for p in fcu.keyframe_points if p.interpolation != 'CONSTANT']
interpos = [p for fcu in fcurves if fcu.data_path == chan for p in fcu.keyframe_points if p.interpolation != 'CONSTANT']
if interpos:
self.alert = f'Animated Rotation ! ({len(interpos)} key not constant)'
break
@@ -344,8 +323,8 @@ class GPTB_OT_realign(bpy.types.Operator):
oframe = context.scene.frame_current
o = bpy.context.object
if o.animation_data and o.animation_data.action:
if o.animation_data.action.fcurves.find('rotation_euler') or o.animation_data.action.fcurves.find('rotation_quaternion'):
if fcurves := utils.get_fcurves(o):
if fcurves.find('rotation_euler') or fcurves.find('rotation_quaternion'):
align_all_frames(reproject=self.reproject)
print(f'\nAnim realign ({time()-t0:.2f}s)')
self.exit(context, oframe)
@@ -414,9 +393,6 @@ class GPTB_OT_batch_reproject_all_frames(bpy.types.Operator):
axis = context.scene.tool_settings.gpencil_sculpt.lock_axis
box.label(text=orient[axis][0], icon=orient[axis][1])
def execute(self, context):
t0 = time()
orient = self.type
@@ -450,12 +426,12 @@ def register():
for cl in classes:
bpy.utils.register_class(cl)
bpy.types.VIEW3D_MT_grease_pencil_edit_context_menu.append(reproject_context_menu)
bpy.types.GPENCIL_MT_cleanup.append(reproject_clean_menu)
bpy.types.VIEW3D_MT_greasepencil_edit_context_menu.append(reproject_context_menu)
bpy.types.VIEW3D_MT_edit_greasepencil_cleanup.append(reproject_clean_menu)
def unregister():
bpy.types.GPENCIL_MT_cleanup.remove(reproject_clean_menu)
bpy.types.VIEW3D_MT_grease_pencil_edit_context_menu.remove(reproject_context_menu)
bpy.types.VIEW3D_MT_edit_greasepencil_cleanup.remove(reproject_clean_menu)
bpy.types.VIEW3D_MT_greasepencil_edit_context_menu.remove(reproject_context_menu)
for cl in reversed(classes):
bpy.utils.unregister_class(cl)
+3 -1
View File
@@ -2,9 +2,11 @@
Blender addon - Various tool to help with grease pencil in animation productions.
### /!\ Main branch is currently broken, in migration to gpv3
**[Download latest](https://git.autourdeminuit.com/autour_de_minuit/gp_toolbox/archive/master.zip)**
**[Download for Blender 4.2 and below](https://git.autourdeminuit.com/autour_de_minuit/gp_toolbox/archive/v3.3.0.zip)**
**[Download for Blender 4.2 and below from release page](https://git.autourdeminuit.com/autour_de_minuit/gp_toolbox/releases)**
**[Demo video](https://www.youtube.com/watch?v=Htgao_uPWNs)**
+1 -1
View File
@@ -4,7 +4,7 @@ Blender addon - Boîte à outils de grease pencil pour la production d'animation
**[Télécharger la dernière version](https://git.autourdeminuit.com/autour_de_minuit/gp_toolbox/archive/master.zip)**
**[Télécharger pour Blender 4.2 ou inférieure](https://git.autourdeminuit.com/autour_de_minuit/gp_toolbox/archive/v3.3.0.zip)**
**[Téléchargement pour Blender 4.2 ou inférieur depuis la page des releases](https://git.autourdeminuit.com/autour_de_minuit/gp_toolbox/releases)**
**[Demo video](https://www.youtube.com/watch?v=Htgao_uPWNs)**
+18 -3
View File
@@ -166,7 +166,9 @@ class GPTB_PT_sidebar_panel(Panel):
else:
col.label(text='No GP object selected')
col.prop(context.scene.gptoolprops, 'edit_lines_opacity')
## Gpv3: not more edit line (use Curve lines)
# col.prop(context.scene.gptoolprops, 'edit_lines_opacity')
# Mention update as notice
# addon_updater_ops.update_notice_box_ui(self, context)
@@ -279,6 +281,8 @@ class GPTB_PT_anim_manager(Panel):
col.use_property_split = False
text, icon = ('Cursor Follow On', 'PIVOT_CURSOR') if context.scene.gptoolprops.cursor_follow else ('Cursor Follow Off', 'CURSOR')
col.prop(context.scene.gptoolprops, 'cursor_follow', text=text, icon=icon)
if context.scene.gptoolprops.cursor_follow:
col.prop(context.scene.gptoolprops, 'cursor_follow_target', text='Target', icon='OBJECT_DATA')
class GPTB_PT_toolbox_playblast(Panel):
@@ -767,9 +771,15 @@ GPTB_PT_palettes_list_ui, # subpanels
def register():
for cls in classes:
bpy.utils.register_class(cls)
if bpy.app.version >= (5,0,0):
bpy.types.GREASE_PENCIL_MT_material_context_menu.append(palette_manager_menu)
# bpy.types.GREASE_PENCIL_MT_material_context_menu.append(palette_manager_menu)
else:
bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu)
bpy.types.DOPESHEET_PT_grease_pencil_mode.append(expose_use_channel_color_pref)
# bpy.types.GPENCIL_MT_material_context_menu.append(palette_manager_menu)
bpy.types.DOPESHEET_PT_grease_pencil_mode.append(expose_use_channel_color_pref)
# bpy.types.DOPESHEET_PT_gpencil_layer_display.append(expose_use_channel_color_pref)
# bpy.types.VIEW3D_HT_header.append(interpolate_header_ui) # WIP
@@ -782,10 +792,15 @@ def unregister():
# bpy.types.VIEW3D_HT_header.remove(interpolate_header_ui) # WIP
bpy.types.DOPESHEET_PT_grease_pencil_mode.remove(expose_use_channel_color_pref)
if bpy.app.version >= (5,0,0):
bpy.types.GREASE_PENCIL_MT_material_context_menu.remove(palette_manager_menu)
# bpy.types.GREASE_PENCIL_MT_material_context_menu.remove(palette_manager_menu)
else:
bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu)
# bpy.types.DOPESHEET_PT_gpencil_layer_display.remove(expose_use_channel_color_pref)
# bpy.types.GPENCIL_MT_material_context_menu.remove(palette_manager_menu)
# bpy.types.DOPESHEET_PT_gpencil_layer_display.remove(expose_use_channel_color_pref)
# if bpy.app.version >= (3,0,0):
# bpy.types.ASSETBROWSER_PT_metadata.remove(asset_browser_ui)
+37 -26
View File
@@ -4,7 +4,7 @@ bl_info = {
"name": "GP toolbox",
"description": "Tool set for Grease Pencil in animation production",
"author": "Samuel Bernou, Christophe Seux",
"version": (4, 0, 0),
"version": (5, 0, 1),
"blender": (4, 3, 0),
"location": "Sidebar (N menu) > Gpencil > Toolbox / Gpencil properties",
"warning": "",
@@ -35,7 +35,7 @@ from . import OP_brushes
from . import OP_file_checker
from . import OP_copy_paste
from . import OP_realign
from . import OP_flat_reproject
# from . import OP_flat_reproject # Disabled
from . import OP_depth_move
from . import OP_key_duplicate_send
from . import OP_layer_manager
@@ -47,6 +47,7 @@ from . import OP_layer_namespace
from . import OP_pseudo_tint
from . import OP_follow_curve
from . import OP_material_move_to_layer
from . import OP_delete_viewbound
# from . import OP_eraser_brush
# from . import TOOL_eraser_brush
from . import handler_draw_cam
@@ -159,23 +160,6 @@ class GPTB_prefs(bpy.types.AddonPreferences):
)
## output settings for automated renders
output_parent_level = IntProperty(
name='Parent level',
description="Go up in folder to define a render path relative to the file in upper directotys",
default=0,
min=0,
max=20
)
output_path : StringProperty(
name="Output path",
description="Path relative to blend to place render",
default="//render", maxlen=0, subtype='DIR_PATH')
playblast_path : StringProperty(
name="Playblast Path",
description="Path to folder for playblasts output",
default="//playblast", maxlen=0, subtype='DIR_PATH')
use_env_palettes : BoolProperty(
name="Use Project Palettes",
@@ -205,11 +189,39 @@ class GPTB_prefs(bpy.types.AddonPreferences):
default=True,
)
if bpy.app.version >= (5, 0, 0):
# DIR_PATH type do not accept relative ("//") without PATH_SUPPORTS_BLEND_RELATIVE since blender 5
brush_path : StringProperty(
name="Brushes directory",
description="Path to brushes containing the blends holding the brushes",
default="//", maxlen=0, subtype='DIR_PATH', options={'PATH_SUPPORTS_BLEND_RELATIVE'})
output_path : StringProperty(
name="Output path",
description="Path relative to blend to place render",
default="//render", maxlen=0, subtype='DIR_PATH', options={'PATH_SUPPORTS_BLEND_RELATIVE'})
playblast_path : StringProperty(
name="Playblast Path",
description="Path to folder for playblasts output",
default="//playblast", maxlen=0, subtype='DIR_PATH', options={'PATH_SUPPORTS_BLEND_RELATIVE'})
else:
brush_path : StringProperty(
name="Brushes directory",
description="Path to brushes containing the blends holding the brushes",
default="//", maxlen=0, subtype='DIR_PATH')#, update = set_palette_path
output_path : StringProperty(
name="Output path",
description="Path relative to blend to place render",
default="//render", maxlen=0, subtype='DIR_PATH')
playblast_path : StringProperty(
name="Playblast Path",
description="Path to folder for playblasts output",
default="//playblast", maxlen=0, subtype='DIR_PATH')
## namespace
separator : StringProperty(
name="Separator",
@@ -336,7 +348,7 @@ class GPTB_prefs(bpy.types.AddonPreferences):
nav_fade_val : FloatProperty(
name='Fade Value',
description='Fade value for other layers when navigating (0=invisible)',
default=0.35, min=0.0, max=0.95, step=1, precision=2)
default=0.1, min=0.0, max=0.95, step=1, precision=2)
nav_limit : FloatProperty(
name='Fade Duration',
@@ -625,9 +637,9 @@ class GPTB_prefs(bpy.types.AddonPreferences):
layout.label(text='Following checks will be made when clicking "Check File" button:')
col = layout.column()
col.use_property_split = True
col.prop(self.fixprops, 'check_only')
col.label(text='If dry run is checked, no modification is done', icon='INFO')
col.label(text='Use Ctrl + Click on "Check File" button to invert the behavior', icon='BLANK1')
# col.prop(self.fixprops, 'check_only')
col.label(text='The Popup list possible fixes, you can then use the "Apply Fixes"', icon='INFO')
# col.label(text='Use Ctrl + Click on "Check File" to abply directly', icon='BLANK1')
col.separator()
col.prop(self.fixprops, 'lock_main_cam')
col.prop(self.fixprops, 'set_scene_res', text=f'Reset Scene Resolution (to {self.render_res_x}x{self.render_res_y})')
@@ -703,8 +715,6 @@ def set_namespace_env(name_env, prop_group):
n.is_project = n.tag in project_pfix
def set_env_properties():
prefs = get_addon_prefs()
fps = os.getenv('FPS')
@@ -793,7 +803,7 @@ addon_modules = (
OP_brushes,
OP_cursor_snap_canvas,
OP_copy_paste,
OP_flat_reproject,
# OP_flat_reproject # Disabled,
OP_realign,
OP_depth_move,
OP_key_duplicate_send,
@@ -805,6 +815,7 @@ addon_modules = (
OP_layer_nav,
OP_follow_curve,
OP_material_move_to_layer,
OP_delete_viewbound,
# OP_eraser_brush,
# TOOL_eraser_brush, # experimental eraser brush
handler_draw_cam,
+2
View File
@@ -99,6 +99,7 @@ def gp_stroke_angle_split (frame, strokes, angle):
splitted_loops = bm_angle_split(bm,angle)
## FIXME: Should use -> drawing.remove_strokes(indices=(0,))
frame.drawing.strokes.remove(stroke_info['stroke'])
for loop in splitted_loops :
loop_info = [{'co':v.co,'strength': v[strength], 'pressure' :v[pressure],'select':v[select]} for v in loop]
@@ -123,6 +124,7 @@ def gp_stroke_uniform_density(cam, frame, strokes, max_spacing):
bm_uniform_density(bm,cam,max_spacing)
## FIXME: Should use -> drawing.remove_strokes(indices=(0,))
frame.strokes.remove(stroke_info['stroke'])
bm.verts.ensure_lookup_table()
+15 -11
View File
@@ -11,10 +11,12 @@ from bpy.props import (
from .OP_cursor_snap_canvas import cursor_follow_update
from .OP_layer_manager import layer_name_build
def change_edit_lines_opacity(self, context):
for gp in bpy.data.grease_pencils:
if not gp.is_annotation:
gp.edit_line_color[3]=self.edit_lines_opacity
## Obsolete: Gpv3 has no edit line color anymore
# def change_edit_lines_opacity(self, context):
# for gp in bpy.data.grease_pencils:
# if not gp.is_annotation:
# gp.edit_line_color[3]=self.edit_lines_opacity
def update_layer_name(self, context):
@@ -31,10 +33,6 @@ def update_layer_name(self, context):
class GP_PG_FixSettings(PropertyGroup):
check_only : BoolProperty(
name="Dry run mode (Check only)",
description="Do not change anything, just print the messages",
default=False, options={'HIDDEN'})
lock_main_cam : BoolProperty(
name="Lock Main Cam",
@@ -182,9 +180,15 @@ class GP_PG_ToolsSettings(PropertyGroup):
name='Cursor Follow', description="3D cursor follow active object animation when activated",
default=False, update=cursor_follow_update)
edit_lines_opacity : FloatProperty(
name="Edit Lines Opacity", description="Change edit lines opacity for all grease pencils",
default=0.5, min=0.0, max=1.0, step=3, precision=2, update=change_edit_lines_opacity)
cursor_follow_target : bpy.props.PointerProperty(
name='Cursor Follow Target',
description="Optional target object to follow for cursor instead of active object",
type=bpy.types.Object, update=cursor_follow_update)
## gpv3 : no edit line color anymore
# edit_lines_opacity : FloatProperty(
# name="Edit Lines Opacity", description="Change edit lines opacity for all grease pencils",
# default=0.5, min=0.0, max=1.0, step=3, precision=2, update=change_edit_lines_opacity)
## render
name_for_current_render : StringProperty(
+356 -105
View File
@@ -5,18 +5,85 @@ import mathutils
import math
import subprocess
from time import time
from math import sqrt
from mathutils import Vector
from sys import platform
## constants values
""" def get_gp_parent(layer) :
if layer.parent_type == "BONE" and layer.parent_bone :
return layer.parent.pose.bones.get(layer.parent_bone)
else :
return layer.parent
"""
## Default stroke and points attributes
stroke_attr = [
'start_cap',
'end_cap',
'softness',
'material_index',
'fill_opacity',
'fill_color',
'cyclic',
'aspect_ratio',
'time_start',
# 'curve_type', # read-only
]
point_attr = [
'position',
'radius',
'rotation',
'opacity',
'vertex_color',
'delta_time',
# 'select',
]
### Attribute value, types and shape
attribute_value_string = {
'FLOAT': "value",
'INT': "value",
'FLOAT_VECTOR': "vector",
'FLOAT_COLOR': "color",
'BYTE_COLOR': "color",
'STRING': "value",
'BOOLEAN': "value",
'FLOAT2': "value",
'INT8': "value",
'INT32_2D': "value",
'QUATERNION': "value",
'FLOAT4X4': "value",
}
attribute_value_dtype = {
'FLOAT': np.float32,
'INT': np.dtype('int'),
'FLOAT_VECTOR': np.float32,
'FLOAT_COLOR': np.float32,
'BYTE_COLOR': np.int8,
'STRING': np.dtype('str'),
'BOOLEAN': np.dtype('bool'),
'FLOAT2': np.float32,
'INT8': np.int8,
'INT32_2D': np.dtype('int'),
'QUATERNION': np.float32,
'FLOAT4X4': np.float32,
}
attribute_value_shape = {
'FLOAT': (),
'INT': (),
'FLOAT_VECTOR': (3,),
'FLOAT_COLOR': (4,),
'BYTE_COLOR': (4,),
'STRING': (),
'BOOLEAN': (),
'FLOAT2':(2,),
'INT8': (),
'INT32_2D': (2,),
'QUATERNION': (4,),
'FLOAT4X4': (4,4),
}
def translate_range(OldValue, OldMin, OldMax, NewMax, NewMin):
return (((OldValue - OldMin) * (NewMax - NewMin)) / (OldMax - OldMin)) + NewMin
@@ -77,7 +144,7 @@ def object_derived_get(ob, scene):
# -----------------
### Bmesh
# region Bmesh
# -----------------
def link_vert(v,ordered_vert) :
@@ -152,9 +219,74 @@ def gp_stroke_to_bmesh(strokes):
# -----------------
### GP Drawing
# region GP Drawing
# -----------------
def layer_active_index(gpl):
'''Get layer list and return index of active layer
Can return None if no active layer found (active item can be a group)
'''
return next((i for i, l in enumerate(gpl) if l == gpl.active), None)
def get_top_layer_from_group(gp, group):
upper_layer = None
for layer in gp.layers:
if layer.parent_group == group:
upper_layer = layer
return upper_layer
def get_closest_active_layer(gp):
'''Get active layer from GP object, getting upper layer if in group
if a group is active, return the top layer of this group
if group is active but no layer in it, return None
'''
if gp.layers.active:
return gp.layers.active
## No active layer, return active from group (can be None !)
return get_top_layer_from_group(gp, gp.layer_groups.active)
def closest_layer_active_index(gp, fallback_index=0):
'''Get active layer index from GP object, getting upper layer if in group
if a group is active, return index at the top layer of this group
if group is active but no layer in it, return fallback_index (0 by default, stack bottom)'''
closest_active_layer = get_closest_active_layer(gp)
if closest_active_layer:
return next((i for i, l in enumerate(gp.layers) if l == closest_active_layer), fallback_index)
return fallback_index
## Check for nested lock
def is_locked(stack_item):
'''Check if passed stack item (layer or group) is locked
either itself or by parent groups'''
if stack_item.lock:
return True
if stack_item.parent_group:
return is_locked(stack_item.parent_group)
return False
def is_parent_locked(stack_item):
'''Check if passed stack item (layer or group) is locked by parent groups'''
if stack_item.parent_group:
return is_locked(stack_item.parent_group)
return False
## Check for nested hide
def is_hidden(stack_item):
'''Check if passed stack item (layer or group) is hidden
either itself or by parent groups'''
if stack_item.hide:
return True
if stack_item.parent_group:
return is_hidden(stack_item.parent_group)
return False
def is_parent_hidden(stack_item):
'''Check if passed stack item (layer or group) is hidden by parent groups'''
if stack_item.parent_group:
return is_hidden(stack_item.parent_group)
return False
def simple_draw_gp_stroke(pts, frame, width = 2, mat_id = 0):
'''
draw basic stroke by passing list of point 3D coordinate
@@ -261,7 +393,7 @@ def remapping(value, leftMin, leftMax, rightMin, rightMax):
return rightMin + (valueScaled * rightSpan)
# -----------------
### GP funcs
# region GP funcs
# -----------------
def get_gp_draw_plane(obj=None, orient=None):
@@ -491,68 +623,137 @@ def copy_stroke_to_frame(s, frame, select=True):
return created stroke
'''
ns = frame.drawing.strokes.new()
## Set strokes attr
stroke_attr = [
'line_width',
'material_index',
'draw_cyclic',
'use_cyclic',
'uv_scale',
'uv_rotation',
'hardness',
'uv_translation',
'vertex_color_fill',
]
frame.drawing.add_strokes([len(s.points)])
ns = frame.drawing.strokes[-1]
# print(len(s.points), 'new:', len(ns.points))
#ns.material_index
## replicate attributes (simple loop)
## TODO : might need to create atribute domain if does not exists in destination
for attr in stroke_attr:
if not hasattr(s, attr):
continue
# print(f'transfer stroke {attr}') # Dbg
setattr(ns, attr, getattr(s, attr))
## create points
point_count = len(s.points)
ns.points.add(len(s.points))
for src_p, dest_p in zip(s.points, ns.points):
for attr in point_attr:
setattr(dest_p, attr, getattr(src_p, attr))
## Define selection
dest_p.select=select
## Set points attr
# for p, np in zip(s.points, ns.points):
flat_list = [0.0] * point_count
flat_uv_fill_list = [0.0, 0.0] * point_count
flat_vector_list = [0.0, 0.0, 0.0] * point_count
flat_color_list = [0.0, 0.0, 0.0, 0.0] * point_count
## Direcly iterate over attribute ?
# src_start = src_dr.curve_offsets[0].value
# src_end = src_start + data_size
# dst_start = dst_dr.curve_offsets[0].value
# dst_end = dst_start + data_size
# for src_idx, dest_idx in zip(range(src_start, src_end),range(dst_start, dst_end)):
# setattr(dest_attr.data[dest_idx], val_type, getattr(source_attr.data[src_idx], val_type))
single_attr = [
'pressure',
'strength',
'uv_factor',
'uv_rotation',
]
for attr in single_attr:
# print(f'transfer point {attr}') # Dbg
s.points.foreach_get(attr, flat_list)
ns.points.foreach_set(attr, flat_list)
# print(f'transfer point co') # Dbg
s.points.foreach_get('co', flat_vector_list)
ns.points.foreach_set('co', flat_vector_list)
# print(f'transfer point uv_fill') # Dbg
s.points.foreach_get('uv_fill', flat_uv_fill_list)
ns.points.foreach_set('uv_fill', flat_uv_fill_list)
# print(f'transfer point vertex_color') # Dbg
s.points.foreach_get('vertex_color', flat_color_list)
ns.points.foreach_set('vertex_color', flat_color_list)
ns.select = select
ns.points.update()
return ns
"""## Works, but do not copy all attributes type (probably ok for GP though)
def bulk_frame_copy_attributes(source_attr, target_attr):
'''Get and apply data as flat numpy array based on attribute type'''
if source_attr.data_type == 'INT':
data = np.empty(len(source_attr.data), dtype=np.int32)
source_attr.data.foreach_get('value', data)
target_attr.data.foreach_set('value', data)
elif source_attr.data_type == 'INT8':
data = np.empty(len(source_attr.data), dtype=np.int8)
source_attr.data.foreach_get('value', data)
target_attr.data.foreach_set('value', data)
elif source_attr.data_type == 'FLOAT':
data = np.empty(len(source_attr.data), dtype=np.float32)
source_attr.data.foreach_get('value', data)
target_attr.data.foreach_set('value', data)
elif source_attr.data_type == 'FLOAT_VECTOR':
data = np.empty(len(source_attr.data) * 3, dtype=np.float32)
source_attr.data.foreach_get('vector', data)
target_attr.data.foreach_set('vector', data)
elif source_attr.data_type == 'FLOAT_COLOR':
data = np.empty(len(source_attr.data) * 4, dtype=np.float32)
source_attr.data.foreach_get('color', data)
target_attr.data.foreach_set('color', data)
elif source_attr.data_type == 'BOOLEAN':
data = np.empty(len(source_attr.data), dtype=bool)
source_attr.data.foreach_get('value', data)
target_attr.data.foreach_set('value', data)
## works in slowmotion (keep as reference for testing)
# def copy_attribute_values(src_dr, dst_dr, source_attr, dest_attr, data_size):
# ## Zip method to copy one by one
# val_type = {'FLOAT_COLOR': 'color','FLOAT_VECTOR': 'vector'}.get(source_attr.data_type, 'value')
# src_start = src_dr.curve_offsets[0].value
# src_end = src_start + data_size
# dst_start = dst_dr.curve_offsets[0].value
# dst_end = dst_start + data_size
# for src_idx, dest_idx in zip(range(src_start, src_end),range(dst_start, dst_end)):
# setattr(dest_attr.data[dest_idx], val_type, getattr(source_attr.data[src_idx], val_type))
"""
def bulk_copy_attributes(source_attr, target_attr):
'''Get and apply data as flat numpy array based on attribute type'''
value_string = attribute_value_string[source_attr.data_type]
dtype = attribute_value_dtype[source_attr.data_type]
shape = attribute_value_shape[source_attr.data_type]
domain_size = len(source_attr.data)
## Need to pass attributes to get domain size
# domain_size = attributes.domain_size(source_attr.domain)
# start = time()
data = np.empty((domain_size, *shape), dtype=dtype).ravel()
source_attr.data.foreach_get(value_string, data)
target_attr.data.foreach_set(value_string, data)
# end = time()
# np_empty = end - start
## np.prod (works, supposedly faster but tested slower)
# data = np.empty(int(domain_size * np.prod(shape)), dtype=dtype)
# source_attr.data.foreach_get(value_string, data)
# target_attr.data.foreach_set(value_string, data)
## np.zeros (works, sometimes faster on big set of attributes)
# start = time()
# data = np.zeros((domain_size, *shape), dtype=dtype)
# source_attr.data.foreach_get(value_string, np.ravel(data))
# target_attr.data.foreach_set(value_string, np.ravel(data))
# end = time()
# np_zero = end - start
# print('np EMPTY faster' if np_empty < np_zero else 'np ZERO faster', source_attr.domain, source_attr.data_type, domain_size)
# print('np_zero', np_zero)
# print('np_empty', np_empty)
# print()
def copy_frame_at(source_frame, layer, frame_number):
'''Copy a frame (source_frame) to a layer at given frame_number'''
source_drawing = source_frame.drawing
# frame_copy_start = time() # time_dbg
frame = layer.frames.new(frame_number)
dr = frame.drawing
dr.add_strokes([len(s.points) for s in source_drawing.strokes])
for attr_name in source_drawing.attributes.keys():
source_attr = source_drawing.attributes[attr_name]
if attr_name not in dr.attributes:
dr.attributes.new(
name=attr_name, type=source_attr.data_type, domain=source_attr.domain)
target_attr = dr.attributes[attr_name]
# start_time = time() # time_dbg-per-attrib
# bulk_frame_copy_attributes(source_attr, target_attr) # only some attributes
bulk_copy_attributes(source_attr, target_attr)
# copy_attribute_values(source_drawing, dr, source_attr, target_attr, source_drawing.attributes.domain_size(source_attr.domain)) # super slow
# end_time = time() # time_dbg-per-attrib
# print(f"copy_attribute '{attr_name}' execution time: {end_time - start_time} seconds") # time_dbg-per-attrib
# frame_copy_end = time() # time_dbg
# print(f"frame copy execution time: {frame_copy_end - frame_copy_start} seconds") # time_dbg
# -----------------
### Vector utils 3d
# region Vector utils 3d
# -----------------
def matrix_transform(coords, matrix):
@@ -613,7 +814,7 @@ def extrapolate_points_by_length(a,b, length):
return b + (ab.normalized() * length)
# -----------------
### Vector utils 2d
# region Vector utils 2d
# -----------------
@@ -666,7 +867,7 @@ def midpoint_2d(p1, p2):
# -----------------
### Collection management
# region Collection management
# -----------------
def set_collection(ob, collection, unlink=True) :
@@ -702,24 +903,11 @@ def set_collection(ob, collection, unlink=True) :
# -----------------
### Path utils
# region Path utils
# -----------------
def get_addon_prefs():
'''
function to read current addon preferences properties
access a prop like this :
prefs = get_addon_prefs()
option_state = prefs.super_special_option
oneliner : get_addon_prefs().super_special_option
'''
import os
addon_name = os.path.splitext(__name__)[0]
preferences = bpy.context.preferences
addon_prefs = preferences.addons[addon_name].preferences
return (addon_prefs)
return bpy.context.preferences.addons[__package__].preferences
def open_addon_prefs():
'''Open addon prefs windows with focus on current addon'''
@@ -823,34 +1011,44 @@ def convert_attr(Attr):
def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'):
'''Show message box with element passed as string or list
if _message if a list of lists:
if first element is "OPERATOR":
List format: ["OPERATOR", operator_id, text, icon, {prop_name: value, ...}]
if sublist have 2 element:
considered a label [text, icon]
if sublist have 3 element:
considered as an operator [ops_id_name, text, icon]
if sublist have 4 element:
considered as a property [object, propname, text, icon]
'''
def draw(self, context):
layout = self.layout
for l in _message:
if isinstance(l, str):
self.layout.label(text=l)
else:
if len(l) == 2: # label with icon
self.layout.label(text=l[0], icon=l[1])
elif len(l) == 3: # ops
self.layout.operator_context = "INVOKE_DEFAULT"
self.layout.operator(l[0], text=l[1], icon=l[2], emboss=False) # <- highligh the entry
layout.label(text=l)
elif l[0] == "OPERATOR": # Special operator case with properties
layout.operator_context = "INVOKE_DEFAULT"
op = layout.operator(l[1], text=l[2], icon=l[3], emboss=False)
if len(l) > 4 and isinstance(l[4], dict):
for prop_name, value in l[4].items():
setattr(op, prop_name, value)
## offset pnale when using row...
# row = self.layout.row()
# row.label(text=l[1])
# row.operator(l[0], icon=l[2])
elif len(l) == 2: # label with icon
layout.label(text=l[0], icon=l[1])
elif len(l) == 3: # ops
layout.operator_context = "INVOKE_DEFAULT"
layout.operator(l[0], text=l[1], icon=l[2], emboss=False) # <- highligh the entry
elif len(l) == 4: # prop
row = layout.row(align=True)
row.label(text=l[2], icon=l[3])
row.prop(l[0], l[1], text='')
if isinstance(_message, str):
_message = [_message]
bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon)
# -----------------
### UI utils
# region UI utils
# -----------------
def refresh_areas():
@@ -966,7 +1164,7 @@ def draw_kmi(km, kmi, layout):
# layout.context_pointer_set("keymap", km)
# -----------------
### linking utility
# region linking utility
# -----------------
def link_objects_in_blend(filepath, obj_name_list, link=True):
@@ -994,7 +1192,7 @@ def check_objects_in_blend(filepath, avoid_camera=True):
# -----------------
### props handling
# region props handling
# -----------------
def iterate_selector(zone, attr, state, info_attr = None, active_access='active'):
@@ -1025,8 +1223,40 @@ def iterate_selector(zone, attr, state, info_attr = None, active_access='active'
return info, bottom
def iterate_active_layer(gpd, state):
'''Iterate active GP layer in stack
gpd: Grease Pencil Data
'''
layers = gpd.layers
l_count = len(layers)
if state: # swap
# info = None
# bottom = None
## Get active layer index
active_index = closest_layer_active_index(gpd, fallback_index=None)
if active_index == None:
## fallback to first layer if nothing found
gpd.layers.active = layers[0]
return
target_index = active_index + state
new_index = target_index % l_count
## set active layer
gpd.layers.active = layers[new_index]
if target_index == l_count:
bottom = 1 # bottom reached, cycle to first
elif target_index < 0:
bottom = -1 # up reached, cycle to last
# info = gpd.layers.active.name
# return info, bottom
# -----------------
### Curve handle
# region Curve handle
# -----------------
def create_curve(location=(0,0,0), direction=(1,0,0), name='curve_path', enter_edit=True, context=None):
@@ -1174,7 +1404,7 @@ def create_follow_path_constraint(ob, curve, follow_curve=False, use_fixed_locat
# -----------------
### Object
# region Object
# -----------------
def go_edit_mode(ob, context=None):
@@ -1221,7 +1451,7 @@ def all_object_modifier_enabled(objects) -> bool:
for o in objects:
if o.type != 'GREASEPENCIL':
continue
for m in o.modifier:
for m in o.modifiers:
if m.show_render and not m.show_viewport:
return False
@@ -1255,6 +1485,30 @@ def has_fully_enabled_anim(o):
return False
return True
def get_fcurves(obj):
'''return f-curves of object's active action'''
if bpy.app.version >= (5, 0, 0):
# action slot
if obj.animation_data and obj.animation_data.action_slot:
active_slot = obj.animation_data.action_slot
return active_slot.id_data.layers[0].strips[0].channelbag(active_slot).fcurves
else:
if obj.animation_data and obj.animation_data.action:
return obj.animation_data.action.fcurves
return []
def get_active_channelbag(obj):
'''return active channelbag in blender 5, else action'''
if bpy.app.version >= (5, 0, 0):
# action slot
if obj.animation_data and obj.animation_data.action_slot:
active_slot = obj.animation_data.action_slot
return active_slot.id_data.layers[0].strips[0].channelbag(active_slot)
else:
if obj.animation_data and obj.animation_data.action:
return obj.animation_data.action
def anim_status(objects) -> tuple((str, str)):
'''Return a tutple of icon string status in ('ALL_ON', 'MIXED', 'ALL_OFF', 'NONE')'''
@@ -1274,8 +1528,9 @@ def anim_status(objects) -> tuple((str, str)):
# off_count += 1
### Consider All channels individually
if o.animation_data and o.animation_data.action:
for grp in o.animation_data.action.groups:
if channelbag := get_active_channelbag(o):
for grp in channelbag.groups:
## Check if groups are muted
if grp.mute:
off_count += 1
@@ -1284,7 +1539,7 @@ def anim_status(objects) -> tuple((str, str)):
count += 1
for fcu in o.animation_data.action.fcurves:
for fcu in channelbag.fcurves:
## Check if fcurves are muted
if fcu.mute:
off_count += 1
@@ -1294,12 +1549,8 @@ def anim_status(objects) -> tuple((str, str)):
if o.type in ('GREASEPENCIL', 'CAMERA'):
datablock = o.data
if datablock.animation_data is None:
continue
if not datablock.animation_data.action:
continue
## Check if object data attributes fcurves are muted
for fcu in datablock.animation_data.action.fcurves:
for fcu in get_fcurves(datablock):
if fcu.mute:
off_count += 1
else:
@@ -1325,7 +1576,7 @@ def gp_modifier_status(objects) -> tuple((str, str)):
## Skip hided object
if o.hide_get() and o.hide_render:
continue
for m in o.modifier:
for m in o.modifiers:
if m.show_render and not m.show_viewport:
off_count += 1
else: