# SPDX-License-Identifier: GPL-2.0-or-later bl_info = { "name": "Simplify Curves+", "author": "testscreenings, Michael Soluyanov", "version": (1, 1, 2), "blender": (2, 80, 0), "location": "3D View, Dopesheet & Graph Editors", "description": "Simplify Curves: 3dview, Dopesheet, Graph. Distance Merge: 3d view curve edit", "warning": "", "doc_url": "{BLENDER_MANUAL_URL}/addons/add_curve/simplify_curves.html", "category": "Add Curve", } """ This script simplifies Curve objects and animation F-Curves This script will also Merge by Distance 3d view curves in edit mode """ import bpy from bpy.props import ( BoolProperty, EnumProperty, FloatProperty, IntProperty, ) import mathutils from math import ( sin, pow, ) from bpy.types import Operator def error_handlers(self, op_name, errors, reports="ERROR"): if self and reports: self.report({'INFO'}, reports + ": some operations could not be performed " "(See Console for more info)") print("\n[Simplify Curves]\nOperator: {}\nErrors: {}\n".format(op_name, errors)) # Check for curve # ### simplipoly algorithm ### # get SplineVertIndices to keep def simplypoly(splineVerts, options): # main vars newVerts = [] # list of vertindices to keep points = splineVerts # list of 3dVectors pointCurva = [] # table with curvatures curvatures = [] # averaged curvatures per vert for p in points: pointCurva.append([]) order = options[3] # order of sliding beziercurves k_thresh = options[2] # curvature threshold dis_error = options[6] # additional distance error # get curvatures per vert for i, point in enumerate(points[: -(order - 1)]): BVerts = points[i: i + order] for b, BVert in enumerate(BVerts[1: -1]): deriv1 = getDerivative(BVerts, 1 / (order - 1), order - 1) deriv2 = getDerivative(BVerts, 1 / (order - 1), order - 2) curva = getCurvature(deriv1, deriv2) pointCurva[i + b + 1].append(curva) # average the curvatures for i in range(len(points)): avgCurva = sum(pointCurva[i]) / (order - 1) curvatures.append(avgCurva) # get distancevalues per vert - same as Ramer-Douglas-Peucker # but for every vert distances = [0.0] # first vert is always kept for i, point in enumerate(points[1: -1]): dist = altitude(points[i], points[i + 2], points[i + 1]) distances.append(dist) distances.append(0.0) # last vert is always kept # generate list of vert indices to keep # tested against averaged curvatures and distances of neighbour verts newVerts.append(0) # first vert is always kept for i, curv in enumerate(curvatures): if (curv >= k_thresh * 0.01 or distances[i] >= dis_error * 0.1): newVerts.append(i) newVerts.append(len(curvatures) - 1) # last vert is always kept return newVerts # get binomial coefficient def binom(n, m): b = [0] * (n + 1) b[0] = 1 for i in range(1, n + 1): b[i] = 1 j = i - 1 while j > 0: b[j] += b[j - 1] j -= 1 return b[m] # get nth derivative of order(len(verts)) bezier curve def getDerivative(verts, t, nth): order = len(verts) - 1 - nth QVerts = [] if nth: for i in range(nth): if QVerts: verts = QVerts derivVerts = [] for i in range(len(verts) - 1): derivVerts.append(verts[i + 1] - verts[i]) QVerts = derivVerts else: QVerts = verts if len(verts[0]) == 3: point = Vector((0, 0, 0)) if len(verts[0]) == 2: point = Vector((0, 0)) for i, vert in enumerate(QVerts): point += binom(order, i) * pow(t, i) * pow(1 - t, order - i) * vert deriv = point return deriv # get curvature from first, second derivative def getCurvature(deriv1, deriv2): if deriv1.length == 0: # in case of points in straight line curvature = 0 return curvature curvature = (deriv1.cross(deriv2)).length / pow(deriv1.length, 3) return curvature # ### Ramer-Douglas-Peucker algorithm ### # get altitude of vert def altitude(point1, point2, pointn): edge1 = point2 - point1 edge2 = pointn - point1 if edge2.length == 0: altitude = 0 return altitude if edge1.length == 0: altitude = edge2.length return altitude alpha = edge1.angle(edge2) altitude = sin(alpha) * edge2.length return altitude # iterate through verts def iterate(points, newVerts, error): new = [] for newIndex in range(len(newVerts) - 1): bigVert = 0 alti_store = 0 for i, point in enumerate(points[newVerts[newIndex] + 1: newVerts[newIndex + 1]]): alti = altitude(points[newVerts[newIndex]], points[newVerts[newIndex + 1]], point) if alti > alti_store: alti_store = alti if alti_store >= error: bigVert = i + 1 + newVerts[newIndex] if bigVert: new.append(bigVert) if new == []: return False return new # get SplineVertIndices to keep def simplify_RDP(splineVerts, options): # main vars error = options[4] # set first and last vert newVerts = [0, len(splineVerts) - 1] # iterate through the points new = 1 while new is not False: new = iterate(splineVerts, newVerts, error) if new: newVerts += new newVerts.sort() return newVerts # ### CURVE GENERATION ### # set bezierhandles to auto def setBezierHandles(newCurve): # Faster: for spline in newCurve.data.splines: for p in spline.bezier_points: p.handle_left_type = 'AUTO' p.handle_right_type = 'AUTO' # get array of new coords for new spline from vertindices def vertsToPoints(newVerts, splineVerts, splineType): # main vars newPoints = [] # array for BEZIER spline output if splineType == 'BEZIER': for v in newVerts: newPoints += splineVerts[v].to_tuple() # array for nonBEZIER output else: for v in newVerts: newPoints += (splineVerts[v].to_tuple()) if splineType == 'NURBS': newPoints.append(1) # for nurbs w = 1 else: # for poly w = 0 newPoints.append(0) return newPoints # ### MAIN OPERATIONS ### def main(context, obj, options, curve_dimension): mode = options[0] output = options[1] degreeOut = options[5] keepShort = options[7] bpy.ops.object.select_all(action='DESELECT') scene = context.scene splines = obj.data.splines.values() # create curvedatablock curve = bpy.data.curves.new("Simple_" + obj.name, type='CURVE') curve.dimensions = curve_dimension # go through splines for spline_i, spline in enumerate(splines): # test if spline is a long enough if len(spline.points) >= 3 or keepShort: # check what type of spline to create if output == 'INPUT': splineType = spline.type else: splineType = output # get vec3 list to simplify if spline.type == 'BEZIER': # get bezierverts splineVerts = [splineVert.co.copy() for splineVert in spline.bezier_points.values()] else: # verts from all other types of curves splineVerts = [splineVert.co.to_3d() for splineVert in spline.points.values()] # simplify spline according to mode if mode == 'DISTANCE': newVerts = simplify_RDP(splineVerts, options) if mode == 'CURVATURE': newVerts = simplypoly(splineVerts, options) # convert indices into vectors3D newPoints = vertsToPoints(newVerts, splineVerts, splineType) # create new spline newSpline = curve.splines.new(type=splineType) # put newPoints into spline according to type if splineType == 'BEZIER': newSpline.bezier_points.add(int(len(newPoints) * 0.33)) newSpline.bezier_points.foreach_set('co', newPoints) else: newSpline.points.add(int(len(newPoints) * 0.25 - 1)) newSpline.points.foreach_set('co', newPoints) # set degree of outputNurbsCurve if output == 'NURBS': newSpline.order_u = degreeOut # splineoptions newSpline.use_endpoint_u = spline.use_endpoint_u # create new object and put into scene newCurve = bpy.data.objects.new("Simple_" + obj.name, curve) coll = context.view_layer.active_layer_collection.collection coll.objects.link(newCurve) newCurve.select_set(True) context.view_layer.objects.active = newCurve newCurve.matrix_world = obj.matrix_world # set bezierhandles to auto setBezierHandles(newCurve) return # get preoperator fcurves def getFcurveData(obj): fcurves = [] for fc in obj.animation_data.action.fcurves: if fc.select: fcVerts = [vcVert.co.to_3d() for vcVert in fc.keyframe_points.values()] fcurves.append(fcVerts) return fcurves def selectedfcurves(obj): fcurves_sel = [] for i, fc in enumerate(obj.animation_data.action.fcurves): if fc.select: fcurves_sel.append(fc) return fcurves_sel # fCurves Main def fcurves_simplify(context, obj, options, fcurves): # main vars mode = options[0] # get indices of selected fcurves fcurve_sel = selectedfcurves(obj) # go through fcurves for fcurve_i, fcurve in enumerate(fcurves): # test if fcurve is long enough if len(fcurve) >= 3: # simplify spline according to mode if mode == 'DISTANCE': newVerts = simplify_RDP(fcurve, options) if mode == 'CURVATURE': newVerts = simplypoly(fcurve, options) # convert indices into vectors3D newPoints = [] # this is different from the main() function for normal curves, different api... for v in newVerts: newPoints.append(fcurve[v]) # remove all points from curve first for i in range(len(fcurve) - 1, 0, -1): fcurve_sel[fcurve_i].keyframe_points.remove(fcurve_sel[fcurve_i].keyframe_points[i]) # put newPoints into fcurve for v in newPoints: fcurve_sel[fcurve_i].keyframe_points.insert(frame=v[0], value=v[1]) return # ### MENU append ### def menu_func(self, context): self.layout.operator("graph.simplify") def menu(self, context): self.layout.operator("curve.simplify", text="Curve Simplify", icon="CURVE_DATA") # ### ANIMATION CURVES OPERATOR ### class GRAPH_OT_simplify(Operator): bl_idname = "graph.simplify" bl_label = "Simplify F-Curves" bl_description = ("Simplify selected Curves\n" "Does not operate on short Splines (less than 3 points)") bl_options = {'REGISTER', 'UNDO'} # Properties opModes = [ ('DISTANCE', 'Distance', 'Distance-based simplification (Poly)'), ('CURVATURE', 'Curvature', 'Curvature-based simplification (RDP)')] mode: EnumProperty( name="Mode", description="Choose algorithm to use", items=opModes ) k_thresh: FloatProperty( name="k", min=0, soft_min=0, default=0, precision=5, description="Threshold" ) pointsNr: IntProperty( name="n", min=5, soft_min=5, max=16, soft_max=9, default=5, description="Degree of curve to get averaged curvatures" ) error: FloatProperty( name="Error", description="Maximum allowed distance error", min=0.0, soft_min=0.0, default=0, precision=5, step = 0.1 ) degreeOut: IntProperty( name="Degree", min=3, soft_min=3, max=7, soft_max=7, default=5, description="Degree of new curve" ) dis_error: FloatProperty( name="Distance error", description="Maximum allowed distance error in Blender Units", min=0, soft_min=0, default=0.0, precision=5 ) fcurves = [] def draw(self, context): layout = self.layout col = layout.column() col.label(text="Distance Error:") col.prop(self, "error", expand=True) @classmethod def poll(cls, context): # Check for animdata obj = context.active_object fcurves = False if obj: animdata = obj.animation_data if animdata: act = animdata.action if act: fcurves = act.fcurves return (obj and fcurves) def execute(self, context): options = [ self.mode, # 0 self.mode, # 1 self.k_thresh, # 2 self.pointsNr, # 3 self.error, # 4 self.degreeOut, # 6 self.dis_error # 7 ] obj = context.active_object if not self.fcurves: self.fcurves = getFcurveData(obj) fcurves_simplify(context, obj, options, self.fcurves) return {'FINISHED'} # ### Curves OPERATOR ### class CURVE_OT_simplify(Operator): bl_idname = "curve.simplify" bl_label = "Simplify Curves" bl_description = ("Simplify the existing Curve based upon the chosen settings\n" "Notes: Needs an existing Curve object,\n" "Outputs a new Curve with the Simple prefix in the name") bl_options = {'REGISTER', 'UNDO'} # Properties opModes = [ ('DISTANCE', 'Distance', 'Distance-based simplification (Poly)'), ('CURVATURE', 'Curvature', 'Curvature-based simplification (RDP)') ] mode: EnumProperty( name="Mode", description="Choose algorithm to use", items=opModes ) SplineTypes = [ ('INPUT', 'Input', 'Same type as input spline'), ('NURBS', 'Nurbs', 'NURBS'), ('BEZIER', 'Bezier', 'BEZIER'), ('POLY', 'Poly', 'POLY') ] output: EnumProperty( name="Output splines", description="Type of splines to output", items=SplineTypes ) k_thresh: FloatProperty( name="k", min=0, soft_min=0, default=0, precision=5, description="Threshold" ) pointsNr: IntProperty( name="n", min=5, soft_min=5, max=9, soft_max=9, default=5, description="Degree of curve to get averaged curvatures" ) error: FloatProperty( name="Error", description="Maximum allowed distance error in Blender Units", min=0, soft_min=0, default=0.0, precision=5, step = 0.1 ) degreeOut: IntProperty( name="Degree", min=3, soft_min=3, max=7, soft_max=7, default=5, description="Degree of new curve" ) dis_error: FloatProperty( name="Distance error", description="Maximum allowed distance error in Blender Units", min=0, soft_min=0, default=0.0 ) keepShort: BoolProperty( name="Keep short splines", description="Keep short splines (less than 3 points)", default=True ) def draw(self, context): layout = self.layout col = layout.column() col.label(text="Distance Error:") col.prop(self, "error", expand=True) col.prop(self, "output", text="Output", icon="OUTLINER_OB_CURVE") if self.output == "NURBS": col.prop(self, "degreeOut", expand=True) col.separator() col.prop(self, "keepShort", expand=True) @classmethod def poll(cls, context): obj = context.active_object return (obj and obj.type == 'CURVE') def execute(self, context): options = [ self.mode, # 0 self.output, # 1 self.k_thresh, # 2 self.pointsNr, # 3 self.error, # 4 self.degreeOut, # 5 self.dis_error, # 6 self.keepShort # 7 ] try: bpy.ops.object.mode_set(mode='OBJECT') obj = context.active_object curve_dimension = obj.data.dimensions main(context, obj, options, curve_dimension) except Exception as e: error_handlers(self, "curve.simplify", e, "Simplify Curves") return {'CANCELLED'} return {'FINISHED'} ## Initial use Curve Remove Doubles ## def main_rd(context, distance = 0.01): selected_Curves = context.selected_objects if bpy.ops.object.mode_set.poll(): bpy.ops.object.mode_set(mode='EDIT') bezier_dellist = [] dellist = [] for curve in selected_Curves: for spline in curve.data.splines: if spline.type == 'BEZIER': if len(spline.bezier_points) > 1: for i in range(0, len(spline.bezier_points)): if i == 0: ii = len(spline.bezier_points) - 1 else: ii = i - 1 dot = spline.bezier_points[i]; dot1 = spline.bezier_points[ii]; while dot1 in bezier_dellist and i != ii: ii -= 1 if ii < 0: ii = len(spline.bezier_points)-1 dot1 = spline.bezier_points[ii] if dot.select_control_point and dot1.select_control_point and (i!=0 or spline.use_cyclic_u): if (dot.co-dot1.co).length < distance: # remove points and recreate hangles dot1.handle_right_type = "FREE" dot1.handle_right = dot.handle_right dot1.co = (dot.co + dot1.co) / 2 bezier_dellist.append(dot) else: # Handles that are on main point position converts to vector, # if next handle are also vector if dot.handle_left_type == 'VECTOR' and (dot1.handle_right - dot1.co).length < distance: dot1.handle_right_type = "VECTOR" if dot1.handle_right_type == 'VECTOR' and (dot.handle_left - dot.co).length < distance: dot.handle_left_type = "VECTOR" else: if len(spline.points) > 1: for i in range(0, len(spline.points)): if i == 0: ii = len(spline.points) - 1 else: ii = i - 1 dot = spline.points[i]; dot1 = spline.points[ii]; while dot1 in dellist and i != ii: ii -= 1 if ii < 0: ii = len(spline.points)-1 dot1 = spline.points[ii] if dot.select and dot1.select and (i!=0 or spline.use_cyclic_u): if (dot.co-dot1.co).length < distance: dot1.co = (dot.co + dot1.co) / 2 dellist.append(dot) bpy.ops.curve.select_all(action = 'DESELECT') for dot in bezier_dellist: dot.select_control_point = True for dot in dellist: dot.select = True bezier_count = len(bezier_dellist) count = len(dellist) bpy.ops.curve.delete(type = 'VERT') bpy.ops.curve.select_all(action = 'DESELECT') return bezier_count + count class Curve_OT_CurveRemvDbs(bpy.types.Operator): """Merge consecutive points that are near to each other""" bl_idname = 'curve.remove_double' bl_label = 'Merge By Distance' bl_options = {'REGISTER', 'UNDO'} distance: bpy.props.FloatProperty(name = 'Distance', default = 0.01, soft_min = 0.001, step = 0.1) @classmethod def poll(cls, context): obj = context.active_object return (obj and obj.type == 'CURVE') def execute(self, context): removed=main_rd(context, self.distance) self.report({'INFO'}, "Removed %d bezier points" % removed) return {'FINISHED'} def menu_func_rd(self, context): self.layout.operator(Curve_OT_CurveRemvDbs.bl_idname, text='Merge By Distance') # Register classes = [ GRAPH_OT_simplify, CURVE_OT_simplify, Curve_OT_CurveRemvDbs, ] def register(): from bpy.utils import register_class for cls in classes: register_class(cls) #bpy.types.GRAPH_MT_channel.append(menu_func) #bpy.types.DOPESHEET_MT_channel.append(menu_func) bpy.types.VIEW3D_MT_curve_add.append(menu) bpy.types.VIEW3D_MT_edit_curve_context_menu.prepend(menu) bpy.types.VIEW3D_MT_edit_curve_context_menu.prepend(menu_func_rd) def unregister(): from bpy.utils import unregister_class for cls in reversed(classes): unregister_class(cls) #bpy.types.GRAPH_MT_channel.remove(menu_func) #bpy.types.DOPESHEET_MT_channel.remove(menu_func) bpy.types.VIEW3D_MT_curve_add.remove(menu) bpy.types.VIEW3D_MT_edit_curve_context_menu.remove(menu) bpy.types.VIEW3D_MT_edit_curve_context_menu.remove(menu_func_rd) if __name__ == "__main__": register()