diff --git a/__init__.py b/__init__.py index 4d88f3e..3999d16 100644 --- a/__init__.py +++ b/__init__.py @@ -35,9 +35,9 @@ def register(): items=[('X-Z', 'Front (X-Z)', ''), ('Y-Z', 'Side (Y-Z)', ''), ('X-Y', 'Top (X-Y)', ''), - ('VIEW', 'View (Beta)', ''), - ('AUTO', 'Auto (Beta)', '')], - default='X-Z', + ('VIEW', 'View', 'Use the current view as the 2D working plane'), + ('AUTO', 'Auto', 'Calculate the 2D plane automatically based on input points and view angle')], + default='AUTO', description='The 2D (local) plane that most add-on operators are working on' ) bpy.types.Scene.nijigp_working_plane_layer_transform = bpy.props.BoolProperty( diff --git a/operators/operator_line.py b/operators/operator_line.py index d702a52..4866128 100644 --- a/operators/operator_line.py +++ b/operators/operator_line.py @@ -28,7 +28,7 @@ def fit_2d_strokes(strokes, search_radius, smoothness_factor = 1, pressure_delta empty_result = None, None, None, None, None, None, None, None try: from scipy.interpolate import splprep, splev - from ..solvers.graph import get_mst_longest_path_from_triangles + from ..solvers.graph import TriangleMst except: if operator: operator.report({"ERROR"}, "Please install Scikit-Image in the Preferences panel.") @@ -84,7 +84,9 @@ def fit_2d_strokes(strokes, search_radius, smoothness_factor = 1, pressure_delta # Triangulation and spanning tree conversion tr_output = {} tr_output['vertices'], _, tr_output['triangles'], _,_,_ = geometry.delaunay_2d_cdt(tr_input['vertices'], [], [], 0, 1e-9) - total_length, path_whole = get_mst_longest_path_from_triangles(tr_output) + mst_builder = TriangleMst() + mst_builder.build_mst(tr_output) + total_length, path_whole = mst_builder.get_longest_path() # The fitting method needs at least 4 points if len(path_whole)<4: diff --git a/operators/operator_mesh.py b/operators/operator_mesh.py index e7ec29b..ab637b6 100644 --- a/operators/operator_mesh.py +++ b/operators/operator_mesh.py @@ -8,6 +8,32 @@ MAX_DEPTH = 4096 +def get_mixed_color(gp_obj, stroke, point_idx = None): + """Get the displayed color by jointly considering the material and vertex colors""" + res = [0,0,0,0] + mat_gp = gp_obj.data.materials[stroke.material_index].grease_pencil + if not point_idx: + # Case of fill color + if gp_obj.data.materials[stroke.material_index].grease_pencil.show_fill: + for i in range(4): + res[i] = mat_gp.fill_color[i] + if hasattr(stroke,'vertex_color_fill'): + alpha = stroke.vertex_color_fill[3] + for i in range(3): + res[i] = linear_to_srgb(res[i] * (1-alpha) + alpha * stroke.vertex_color_fill[i]) + return res + else: + # Case of line point color + point = stroke.points[point_idx] + if gp_obj.data.materials[stroke.material_index].grease_pencil.show_stroke: + for i in range(4): + res[i] = mat_gp.color[i] + if hasattr(point,'vertex_color'): + alpha = point.vertex_color[3] + for i in range(3): + res[i] = linear_to_srgb(res[i] * (1-alpha) + alpha * point.vertex_color[i]) + return res + def apply_mirror_in_depth(obj, inv_mat, loc = (0,0,0)): """Apply a Mirror modifier in the Object mode with given center and axis""" obj.modifiers.new(name="nijigp_Mirror", type='MIRROR') @@ -26,6 +52,37 @@ def apply_mirror_in_depth(obj, inv_mat, loc = (0,0,0)): bpy.ops.object.modifier_apply("EXEC_DEFAULT", modifier = "nijigp_Mirror") bpy.data.objects.remove(empty_object) +class CommonMeshConfig: + postprocess_double_sided: bpy.props.BoolProperty( + name='Double-Sided', + default=True, + description='Make the mesh symmetric to the working plane' + ) + vertical_gap: bpy.props.FloatProperty( + name='Vertical Gap', + default=0.01, min=0, + unit='LENGTH', + description='Mininum vertical space between generated meshes' + ) + ignore_mode: bpy.props.EnumProperty( + name='Ignore', + items=[('NONE', 'None', ''), + ('LINE', 'All Lines', ''), + ('OPEN', 'All Open Lines', '')], + default='NONE', + description='Skip strokes without fill' + ) + reuse_material: bpy.props.BoolProperty( + name='Reuse Materials', + default=True, + description='Do not create a new material if it exists' + ) + keep_original: bpy.props.BoolProperty( + name='Keep Original', + default=True, + description='Do not delete the original stroke' + ) + class MeshManagement(bpy.types.Operator): """Manage mesh objects generated from the active GPencil object""" bl_idname = "gpencil.nijigp_mesh_management" @@ -58,7 +115,7 @@ def execute(self, context): return {'FINISHED'} -class MeshGenerationByNormal(bpy.types.Operator): +class MeshGenerationByNormal(CommonMeshConfig, bpy.types.Operator): """Generate a planar mesh with an interpolated normal map calculated from the selected strokes""" bl_idname = "gpencil.nijigp_mesh_generation_normal" bl_label = "Convert to Meshes by Normal Interpolation" @@ -72,25 +129,6 @@ class MeshGenerationByNormal(bpy.types.Operator): default='NORMAL', description='Generate either a normal map or real 3D structure' ) - postprocess_double_sided: bpy.props.BoolProperty( - name='Double-Sided', - default=True, - description='Make the mesh symmetric to the working plane' - ) - vertical_gap: bpy.props.FloatProperty( - name='Vertical Gap', - default=0.01, min=0, - unit='LENGTH', - description='Mininum vertical space between generated meshes' - ) - ignore_mode: bpy.props.EnumProperty( - name='Ignore', - items=[('NONE', 'None', ''), - ('LINE', 'All Lines', ''), - ('OPEN', 'All Open Lines', '')], - default='NONE', - description='Skip strokes without fill' - ) mesh_style: bpy.props.EnumProperty( name='Mesh Style', items=[('TRI', 'Delaunay Triangulation', ''), @@ -146,16 +184,6 @@ class MeshGenerationByNormal(bpy.types.Operator): default='Principled BSDF', search=lambda self, context, edit_text: get_material_list(self.mesh_type, bpy.context.engine) ) - reuse_material: bpy.props.BoolProperty( - name='Reuse Materials', - default=True, - description='Do not create a new material if it exists' - ) - keep_original: bpy.props.BoolProperty( - name='Keep Original', - default=True, - description='Do not delete the original stroke' - ) def draw(self, context): layout = self.layout @@ -422,7 +450,7 @@ def process_single_stroke(i, co_list, mask_indices = []): else: bm.faces.new(v_list) - # Normal and height calculation + # Attribute interpolation based on 2D distance maxmin_dist = 0. depth_offset = np.cos(self.max_vertical_angle) / np.sqrt(1 + (self.vertical_scale**2 - 1) * @@ -460,19 +488,9 @@ def process_single_stroke(i, co_list, mask_indices = []): (co_2d[1]-v_min)/(v_max-v_min)) # Set vertex color from the stroke's both vertex and material fill colors - fill_base_color = [1,1,1,1] - if current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.show_fill: - fill_base_color[0] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[0] - fill_base_color[1] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[1] - fill_base_color[2] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[2] - fill_base_color[3] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[3] - if hasattr(stroke_list[i],'vertex_color_fill'): - alpha = stroke_list[i].vertex_color_fill[3] - fill_base_color[0] = fill_base_color[0] * (1-alpha) + alpha * stroke_list[i].vertex_color_fill[0] - fill_base_color[1] = fill_base_color[1] * (1-alpha) + alpha * stroke_list[i].vertex_color_fill[1] - fill_base_color[2] = fill_base_color[2] * (1-alpha) + alpha * stroke_list[i].vertex_color_fill[2] + fill_base_color = get_mixed_color(current_gp_obj, stroke_list[i]) for v in bm.verts: - v[vertex_color_layer] = [linear_to_srgb(fill_base_color[0]), linear_to_srgb(fill_base_color[1]), linear_to_srgb(fill_base_color[2]), fill_base_color[3]] + v[vertex_color_layer] = fill_base_color v[vertex_start_frame_layer] = frame_range[0] v[vertex_end_frame_layer] = frame_range[1] @@ -577,27 +595,13 @@ def process_single_stroke(i, co_list, mask_indices = []): bpy.ops.object.mode_set(mode='OBJECT') return {'FINISHED'} -class MeshGenerationByOffsetting(bpy.types.Operator): +class MeshGenerationByOffsetting(CommonMeshConfig, bpy.types.Operator): """Generate an embossed mesh by offsetting the selected strokes""" bl_idname = "gpencil.nijigp_mesh_generation_offset" bl_label = "Convert to Meshes by Offsetting" bl_category = 'View' bl_options = {'REGISTER', 'UNDO'} - vertical_gap: bpy.props.FloatProperty( - name='Vertical Gap', - default=0, min=0, - unit='LENGTH', - description='Mininum vertical space between generated meshes' - ) - ignore_mode: bpy.props.EnumProperty( - name='Ignore', - items=[('NONE', 'None', ''), - ('LINE', 'All Lines', ''), - ('OPEN', 'All Open Lines', '')], - default='NONE', - description='Skip strokes without fill' - ) offset_amount: bpy.props.FloatProperty( name='Offset', default=0.1, soft_min=0, unit='LENGTH', @@ -631,16 +635,6 @@ class MeshGenerationByOffsetting(bpy.types.Operator): default='DEFAULT', description='Method of creating side faces' ) - keep_original: bpy.props.BoolProperty( - name='Keep Original', - default=True, - description='Do not delete the original stroke' - ) - postprocess_double_sided: bpy.props.BoolProperty( - name='Double-Sided', - default=True, - description='Make the mesh symmetric to the working plane' - ) postprocess_shade_smooth: bpy.props.BoolProperty( name='Shade Smooth', default=False, @@ -676,11 +670,6 @@ class MeshGenerationByOffsetting(bpy.types.Operator): default='Principled BSDF', search=lambda self, context, edit_text: get_material_list('MESH', bpy.context.engine) ) - reuse_material: bpy.props.BoolProperty( - name='Reuse Materials', - default=True, - description='Do not create a new material if it exists' - ) def draw(self, context): layout = self.layout @@ -912,19 +901,9 @@ def process_single_stroke(i, co_list): bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=self.merge_distance) # Set vertex attributes - fill_base_color = [1,1,1,1] - if current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.show_fill: - fill_base_color[0] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[0] - fill_base_color[1] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[1] - fill_base_color[2] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[2] - fill_base_color[3] = current_gp_obj.data.materials[stroke_list[i].material_index].grease_pencil.fill_color[3] - if hasattr(stroke_list[i],'vertex_color_fill'): - alpha = stroke_list[i].vertex_color_fill[3] - fill_base_color[0] = fill_base_color[0] * (1-alpha) + alpha * stroke_list[i].vertex_color_fill[0] - fill_base_color[1] = fill_base_color[1] * (1-alpha) + alpha * stroke_list[i].vertex_color_fill[1] - fill_base_color[2] = fill_base_color[2] * (1-alpha) + alpha * stroke_list[i].vertex_color_fill[2] + fill_base_color = get_mixed_color(current_gp_obj, stroke_list[i]) for v in bm.verts: - v[vertex_color_layer] = [linear_to_srgb(fill_base_color[0]), linear_to_srgb(fill_base_color[1]), linear_to_srgb(fill_base_color[2]), fill_base_color[3]] + v[vertex_color_layer] = fill_base_color v[vertex_start_frame_layer] = frame_range[0] v[vertex_end_frame_layer] = frame_range[1] diff --git a/solvers/graph.py b/solvers/graph.py index 3f5cbc1..778a294 100644 --- a/solvers/graph.py +++ b/solvers/graph.py @@ -2,71 +2,76 @@ from mathutils import * from scipy.sparse import csr_matrix, csgraph -def get_mst_longest_path_from_triangles(tr_output): +class TriangleMst: """ - Convert triangulated strokes to a minimum spanning stree (MST), then calculate its longest path through BFS. - This function is supposed to be used in the line fitting operators + A class that converts triangulated strokes to a minimum spanning stree (MST) and calculates different metrics """ - def e_dist(i,j): - src = tr_output['vertices'][i] - dst = tr_output['vertices'][j] - res = np.sqrt((dst[0]-src[0])**2 + (dst[1]-src[1])**2) - return max(res, 1e-9) - - # Graph and tree construction: vertex -> node, edge length -> link weight - # All input/output graphs are undirected - num_vert = len(tr_output['vertices']) - row, col, data = [], [], [] - for f in tr_output['triangles']: - row += [f[0], f[1], f[2]] - col += [f[1], f[2], f[0]] - data += [e_dist(f[0], f[1]), e_dist(f[2], f[1]), e_dist(f[0], f[2])] - dist_graph = csr_matrix((data, (row, col)), shape=(num_vert, num_vert)) - mst = csgraph.minimum_spanning_tree(dist_graph) - - # Find two ends of the longest path in the tree by executing BFS twice - def tree_bfs(graph, idx): - dist_map = {idx: 0} - predecessor_map = {idx: None} - max_dist = 0 - farthest_idx = idx - queue = [idx] - pointer = 0 - while pointer < len(queue): - node = queue[pointer] - for next_node in graph.getcol(node).nonzero()[0]: - if next_node not in dist_map: - dist_value = dist_map[node] + graph[next_node,node] - if max_dist < dist_value: - max_dist = dist_value - farthest_idx = next_node - dist_map[next_node] = dist_value - predecessor_map[next_node] = node - queue.append(next_node) - for next_node in graph.getrow(node).nonzero()[1]: - if next_node not in dist_map: - dist_value = dist_map[node] + graph[node,next_node] - if max_dist < dist_value: - max_dist = dist_value - farthest_idx = next_node - dist_map[next_node] = dist_value - predecessor_map[next_node] = node - queue.append(next_node) - pointer += 1 - return predecessor_map, max_dist, farthest_idx - - _, _, src_idx = tree_bfs(mst, 0) - predecessor_map, total_length, dst_idx = tree_bfs(mst, src_idx) - - # Trace the map back to get the whole path - path_whole = [dst_idx] - predecessor = predecessor_map[dst_idx] - while predecessor != src_idx and predecessor in predecessor_map: - path_whole.append(predecessor) - predecessor = predecessor_map[predecessor] - path_whole.append(src_idx) + mst: csgraph + def build_mst(self, tr_output): + def e_dist(i,j): + src = tr_output['vertices'][i] + dst = tr_output['vertices'][j] + res = np.sqrt((dst[0]-src[0])**2 + (dst[1]-src[1])**2) + return max(res, 1e-9) + # Graph and tree construction: vertex -> node, edge length -> link weight + # All input/output graphs are undirected + num_vert = len(tr_output['vertices']) + row, col, data = [], [], [] + for f in tr_output['triangles']: + row += [f[0], f[1], f[2]] + col += [f[1], f[2], f[0]] + data += [e_dist(f[0], f[1]), e_dist(f[2], f[1]), e_dist(f[0], f[2])] + dist_graph = csr_matrix((data, (row, col)), shape=(num_vert, num_vert)) + self.mst = csgraph.minimum_spanning_tree(dist_graph) + return self.mst - return total_length, path_whole + def get_longest_path(self): + """ + The longest path of MST is required by the line fitting operators + """ + # Find two ends of the longest path in the tree by executing BFS twice + def tree_bfs(graph, idx): + dist_map = {idx: 0} + predecessor_map = {idx: None} + max_dist = 0 + farthest_idx = idx + queue = [idx] + pointer = 0 + while pointer < len(queue): + node = queue[pointer] + for next_node in graph.getcol(node).nonzero()[0]: + if next_node not in dist_map: + dist_value = dist_map[node] + graph[next_node,node] + if max_dist < dist_value: + max_dist = dist_value + farthest_idx = next_node + dist_map[next_node] = dist_value + predecessor_map[next_node] = node + queue.append(next_node) + for next_node in graph.getrow(node).nonzero()[1]: + if next_node not in dist_map: + dist_value = dist_map[node] + graph[node,next_node] + if max_dist < dist_value: + max_dist = dist_value + farthest_idx = next_node + dist_map[next_node] = dist_value + predecessor_map[next_node] = node + queue.append(next_node) + pointer += 1 + return predecessor_map, max_dist, farthest_idx + + _, _, src_idx = tree_bfs(self.mst, 0) + predecessor_map, total_length, dst_idx = tree_bfs(self.mst, src_idx) + + # Trace the map back to get the whole path + path_whole = [dst_idx] + predecessor = predecessor_map[dst_idx] + while predecessor != src_idx and predecessor in predecessor_map: + path_whole.append(predecessor) + predecessor = predecessor_map[predecessor] + path_whole.append(src_idx) + + return total_length, path_whole class SmartFillSolver: tr_map: dict