Skip to content
Snippets Groups Projects
curve_simplify.py 21.3 KiB
Newer Older
  • Learn to ignore specific revisions
  • # ##### BEGIN GPL LICENSE BLOCK #####
    #
    #  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 2
    #  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
    #  MERCHANTABILITY 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, write to the Free Software Foundation,
    #  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
    #
    # ##### END GPL LICENSE BLOCK #####
    
    bl_info = {
    
        "name": "Simplify Curves+",
        "author": "testscreenings, Michael Soluyanov",
        "version": (1, 1, 1),
    
        "blender": (2, 80, 0),
    
        "location": "3D View, Dopesheet & Graph Editors",
        "description": "Simplify Curves: 3dview, Dopesheet, Graph. Distance Merge: 3d view curve edit",
    
        "warning": "",
    
        "wiki_url": "https://wiki.blender.org/index.php/Extensions:2.6/Py/"
    
                    "Scripts/Curve/Curve_Simplify",
        "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
    
    from bpy.props import (
    
        BoolProperty,
        EnumProperty,
        FloatProperty,
        IntProperty,
    )
    
    from math import (
    
    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
    
        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(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(len(curvatures) - 1)  # last vert is always kept
    
    # get binomial coefficient
    def binom(n, m):
    
        b = [0] * (n + 1)
    
        for i in range(1, n + 1):
    
                b[j] += b[j - 1]
                j -= 1
    
    # get nth derivative of order(len(verts)) bezier curve
    def getDerivative(verts, t, nth):
        order = len(verts) - 1 - nth
        QVerts = []
    
    Florian Meyer's avatar
    Florian Meyer committed
    
        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])
    
    Florian Meyer's avatar
    Florian Meyer committed
                QVerts = derivVerts
        else:
            QVerts = verts
    
        if len(verts[0]) == 3:
    
            point = Vector((0, 0, 0))
    
    Florian Meyer's avatar
    Florian Meyer committed
        if len(verts[0]) == 2:
    
            point = Vector((0, 0))
    
    Florian Meyer's avatar
    Florian Meyer committed
    
    
        for i, vert in enumerate(QVerts):
    
            point += binom(order, i) * pow(t, i) * pow(1 - t, order - i) * vert
    
    Florian Meyer's avatar
    Florian Meyer committed
    
    
    # get curvature from first, second derivative
    def getCurvature(deriv1, deriv2):
    
        if deriv1.length == 0:  # in case of points in straight line
    
        curvature = (deriv1.cross(deriv2)).length / pow(deriv1.length, 3)
    
    
    # ### 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
    
        altitude = sin(alpha) * edge2.length
    
    # iterate through verts
    def iterate(points, newVerts, error):
        new = []
    
        for newIndex in range(len(newVerts) - 1):
    
            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
    
    Florian Meyer's avatar
    Florian Meyer committed
                    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):
    
        error = options[4]
    
        # set first and last vert
    
        newVerts = [0, len(splineVerts) - 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
    
    
    # ### 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) >= 7 or keepShort:
    
                # check what type of spline to create
    
                if output == 'INPUT':
                    splineType = spline.type
                else:
                    splineType = output
    
                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()]
    
                    newVerts = simplify_RDP(splineVerts, options)
    
    
                    newVerts = simplypoly(splineVerts, options)
    
    
                # convert indices into vectors3D
    
                newPoints = vertsToPoints(newVerts, splineVerts, splineType)
    
    
                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
    
    
        # set bezierhandles to auto
        setBezierHandles(newCurve)
    
        return
    
    
    
    # get preoperator fcurves
    
    Florian Meyer's avatar
    Florian Meyer committed
    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()]
    
    Florian Meyer's avatar
    Florian Meyer committed
                fcurves.append(fcVerts)
        return fcurves
    
    
    Florian Meyer's avatar
    Florian Meyer committed
    def selectedfcurves(obj):
        fcurves_sel = []
        for i, fc in enumerate(obj.animation_data.action.fcurves):
    
            if fc.select:
    
    Florian Meyer's avatar
    Florian Meyer committed
                fcurves_sel.append(fc)
        return fcurves_sel
    
    
    Florian Meyer's avatar
    Florian Meyer committed
    def fcurves_simplify(context, obj, options, fcurves):
        # main vars
        mode = options[0]
    
    
        # get indices of selected fcurves
    
    Florian Meyer's avatar
    Florian Meyer committed
        fcurve_sel = selectedfcurves(obj)
    
    Florian Meyer's avatar
    Florian Meyer committed
        # go through fcurves
        for fcurve_i, fcurve in enumerate(fcurves):
            # test if fcurve is long enough
            if len(fcurve) >= 7:
                # simplify spline according to mode
    
    Florian Meyer's avatar
    Florian Meyer committed
                    newVerts = simplify_RDP(fcurve, options)
    
    
    Florian Meyer's avatar
    Florian Meyer committed
                    newVerts = simplypoly(fcurve, options)
    
    
                # convert indices into vectors3D
    
    Florian Meyer's avatar
    Florian Meyer committed
                newPoints = []
    
                # this is different from the main() function for normal curves, different api...
    
    Florian Meyer's avatar
    Florian Meyer committed
                for v in newVerts:
                    newPoints.append(fcurve[v])
    
                # remove all points from curve first
                for i in range(len(fcurve) - 1, 0, -1):
    
    Florian Meyer's avatar
    Florian Meyer committed
                    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])
    
    # ### 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):
    
    Florian Meyer's avatar
    Florian Meyer committed
        bl_idname = "graph.simplify"
    
        bl_label = "Simplify F-Curves"
        bl_description = ("Simplify selected Curves\n"
                          "Does not operate on short Splines (less than 6 points)")
    
    Florian Meyer's avatar
    Florian Meyer committed
        bl_options = {'REGISTER', 'UNDO'}
    
    
    Florian Meyer's avatar
    Florian Meyer committed
        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=3,
                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=3
                )
    
        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=3
                )
    
    Florian Meyer's avatar
    Florian Meyer committed
        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
    
    Florian Meyer's avatar
    Florian Meyer committed
            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)
    
    Florian Meyer's avatar
    Florian Meyer committed
    
        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
                    ]
    
    Florian Meyer's avatar
    Florian Meyer committed
    
            obj = context.active_object
    
            if not self.fcurves:
                self.fcurves = getFcurveData(obj)
    
    Florian Meyer's avatar
    Florian Meyer committed
            fcurves_simplify(context, obj, options, self.fcurves)
    
            return {'FINISHED'}
    
    
    # ### Curves OPERATOR ###
    class CURVE_OT_simplify(Operator):
    
        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")
    
                ('DISTANCE', 'Distance', 'Distance-based simplification (Poly)'),
    
                ('CURVATURE', 'Curvature', 'Curvature-based simplification (RDP)')
                ]
    
        mode: EnumProperty(
    
                name="Mode",
                description="Choose algorithm to use",
                items=opModes
                )
    
                ('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=3,
                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=3
                )
    
        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 7 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'}
    
    ## Initial use Curve Remove Doubles ##
    
    def main(context, distance = 0.01):
    
        obj = context.active_object
        dellist = []
    
        for spline in obj.data.splines:
            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 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
                            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"
    
    
    
        bpy.ops.curve.select_all(action = 'DESELECT')
    
        for dot in dellist:
            dot.select_control_point = True
    
        count = len(dellist)
    
        bpy.ops.curve.delete(type = 'VERT')
    
        bpy.ops.curve.select_all(action = 'SELECT')
    
        return count
    
    
    
    class Curve_OT_CurveRemvDbs(bpy.types.Operator):
        """Merge consecutive points that are near to each other"""
        bl_idname = 'curve.remove_doubles'
        bl_label = 'Merge By Distance'
        bl_options = {'REGISTER', 'UNDO'}
    
        distance: bpy.props.FloatProperty(name = 'Distance', default = 0.01, min = 0.0001, max = 10.0, step = 1)
    
        @classmethod
        def poll(cls, context):
            obj = context.active_object
            return (obj and obj.type == 'CURVE')
    
        def execute(self, context):
            removed=main(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')
    
    classes = [
        GRAPH_OT_simplify,
        CURVE_OT_simplify,
    
        Curve_OT_CurveRemvDbs,
    
        from bpy.utils import register_class
        for cls in classes:
            register_class(cls)
    
    Brendon Murphy's avatar
    Brendon Murphy committed
        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)
    
    
    Brendon Murphy's avatar
    Brendon Murphy committed
        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)
    
        register()