소스 검색

Cache curves to avoid re-calculation

note that this implementation still detects which curves to cache at
execution time - maybe we can batch them all in the beginning since this
is so fast to calculate (almost all of the time is spent on updating
the deps graph, which means we might even get a speedup by evaluating
the depth graph one time to get all the meshes)

this commit also removed/replaced some of the old chaintools code
it served me well. sad to see it go...
but it is terrible and I am ashamed of myself for writing it
Joseph Brandenburg 1 주 전
부모
커밋
b2c4be425e
3개의 변경된 파일과 455개의 추가작업 그리고 329개의 파일을 삭제
  1. 225 214
      misc_nodes.py
  2. 136 41
      utilities/utilities.py
  3. 94 74
      xForm_nodes.py

+ 225 - 214
misc_nodes.py

@@ -72,6 +72,167 @@ def TellClasses():
              UtilityPrint,
             ]
 
+# This cache is used for storing evaluated curve matrices
+# the idea is to get the data once and hold onto it
+# this prevents numerous wasteful calls to dg.update()
+CURVE_LUT_CACHE = {}
+CURVE_HASHES = {}
+SPLINE_HASHES ={}
+
+# todo: make a "matrix from 2 u" function, a "matrix for segments" function
+# basically I want the SplineLUT to handle all curve matrix getters
+class SplineLUT:
+    __slots__ = ("u_sample_points", "positions", "tangents", "normals", "hash", "curve_name")
+
+    def __init__(self, u_sample_points, positions, tangents, normals, hash, curve_name):
+        # points: ordered iterable of (u, position, tangent, normal)
+        # Each vector is an iterable of 3 floats.
+        self.u_sample_points = u_sample_points; self.hash = hash; self.curve_name = curve_name
+        self.positions = positions; self.tangents = tangents; self.normals = normals
+
+    def data_at_u(self, u):
+        if u <= 0 + FLOAT_EPSILON: return self.positions[0], self.tangents[0], self.normals[0]
+        elif u >= 1 - FLOAT_EPSILON: return self.positions[-1], self.tangents[-1], self.normals[-1]
+        else:
+            u_sample_points = self.u_sample_points
+            # get the index in the list just above the desired u value
+            from bisect import bisect_right # this finds an "insertion point" quickly
+            i = bisect_right(u_sample_points, u) 
+            u0 = u_sample_points[i-1]; u1 = u_sample_points[i]
+            if u - FLOAT_EPSILON >= u0 and u0 <= u + FLOAT_EPSILON :
+                factor = (u - u0) / (u1 - u0)
+                position = self.positions[i-1].lerp( self.positions[i], factor)
+                tangent = self.tangents[i-1].lerp( self.tangents[i], factor).normalized()
+                normal = self.normals[i-1].lerp( self.normals[i], factor).normalized()
+                return position, tangent, normal
+            else: # we're exactly on the vertex.
+                return self.positions[i-1], self.tangents[i-1], self.normals[i-1]
+    
+    def matrix_at_u(self, u):
+        from mathutils import Matrix
+        position, tangent, normal = self.data_at_u(u)
+        normal = (normal - tangent * normal.dot(tangent)).normalized()
+        bitangent = normal.cross(tangent).normalized()
+        matrix = Matrix.Identity(3)
+        matrix[0] = normal
+        matrix[1] = tangent
+        matrix[2] = bitangent
+        matrix.transpose(); matrix = matrix.to_4x4()
+        matrix.translation = position
+        return matrix
+
+    def matrix_from_start_end_u(self, start_u, end_u):
+        from mathutils import Matrix
+        position_start, _tangent_start, normal_start = self.data_at_u(start_u)
+        position_end, _tangent_end, _normal_end = self.data_at_u(end_u)
+        tangent = position_end - position_start
+        length = tangent.length
+        if tangent.length_squared < FLOAT_EPSILON:
+            raise RuntimeError(f"Failed to read the curve {self.curve_name}.")
+        tangent.normalize()
+        normal = (normal_start - tangent * normal_start.dot(tangent)).normalized()
+        bitangent = normal.cross(tangent).normalized()
+        matrix = Matrix.Identity(3)
+        matrix[0] = normal
+        matrix[1] = tangent
+        matrix[2] = bitangent
+        matrix.transpose(); matrix = matrix.to_4x4()
+        matrix.translation = position_start
+        matrix[3][3] = length # set the lenght with our HACK metadata thing
+        return matrix
+
+    def matrices_from_u_segments(self, u_values=[]):
+        return tuple( self.matrix_from_start_end_u(u_values[i], u_values[i+1]) for i in range(len(u_values)-1) )
+
+    def point_and_u_at_nearest_point_on_curve(self, point):
+        from mathutils.geometry import intersect_point_line
+        # because kd_find can return any number of points it always returns a list
+        nearest_point, index, distance = kd_find(self, self.positions, ref_pt=point, )[0]
+        # ONE of the segments this point belongs to will have the right value
+        max_index = len(self.u_sample_points)-1
+        if index >= 0 and index < max_index: # check the line between the found point and the next point
+            closest_point, parametric_distance_to = intersect_point_line(
+                            point, self.positions[index], self.positions[index + 1],)
+            if parametric_distance_to > 0 - FLOAT_EPSILON and parametric_distance_to < 1 + FLOAT_EPSILON:
+                # we have found our point
+                a = self.u_sample_points[index]; b = self.u_sample_points[index + 1]
+                return closest_point, (a + (b - a) * parametric_distance_to)
+        if index > 0 and index <= max_index: # check the previous segment
+            closest_point, parametric_distance_to = intersect_point_line(
+                            point, self.positions[index], self.positions[index - 1],)
+            if parametric_distance_to > 0 - FLOAT_EPSILON and parametric_distance_to < 1 + FLOAT_EPSILON:
+                # we have found our point
+                a = self.u_sample_points[index]; b = self.u_sample_points[index - 1]
+                return closest_point, (a + (b - a) * parametric_distance_to)
+
+    def __repr__(self):
+        return f"Cached curve {self.curve_name}"
+
+
+def hash_spline(spline) -> int:
+    # explicit tuple because Python will make a freakin' generator object otherwise
+    spline_metadata = ( spline.type, spline.resolution_u, spline.order_u, 
+                        spline.use_cyclic_u, spline.use_endpoint_u,
+                        spline.tilt_interpolation, spline.radius_interpolation, )
+    if spline.type == 'BEZIER':
+        points_data = tuple( ( *bp.co, *bp.handle_left, *bp.handle_right,
+                                bp.handle_right_type, bp.handle_left_type,
+                                bp.tilt, bp.radius) for bp in spline.bezier_points )
+    else:
+        points_data = tuple( (*p.co, p.tilt, p.radius ) for p in spline.points )
+    return hash( (spline_metadata, points_data) )
+
+def hash_curve(curve, spline_hashes) -> int:
+    curve_metadata = ( curve.dimensions, curve.twist_mode, curve.resolution_u, )
+    return hash( (curve_metadata, spline_hashes) )
+
+def fill_curve_lut(curve_name, bContext):
+    curve = bpy_object_get_guarded( curve_name )
+
+    if not curve: raise RuntimeError(f"WARN: No curve found for {curve_name}.")
+    elif curve.type != "CURVE": raise RuntimeError(f"WARN: Object {curve_name} is not a curve.")
+    else:
+        spline_hashes = tuple( hash_spline(spline) for spline in curve.data.splines)
+        curve_hash = hash_curve(curve.data, spline_hashes)
+        SPLINE_HASHES[curve.name] = spline_hashes; CURVE_HASHES[curve_name] = curve_hash
+        if existing_cache := CURVE_LUT_CACHE.get(curve_name):
+            if existing_cache['hash'] == curve_hash: return
+        mesh = get_mesh_from_curve(curve_name, "_MANTIS_CACHE_CURVE", bContext)
+        curve_lut_cache = []
+        from .utilities.utilities import all_data_from_ribbon, DetectRibbons
+        for i, ribbon in enumerate(DetectRibbons(mesh)):
+            spline_hash = spline_hashes[i]
+            if existing_cache := CURVE_LUT_CACHE.get(curve_name):
+                if len(existing_cache['spline_LUT']) >= i:
+                    if existing_cache['spline_LUT'][i].hash == spline_hash: continue
+            # othewise we need to get the data
+            # if I ever decide to use the radius in Mantis I'll add it to the LUT
+            u_samples, positions, normals, tangents, radii = all_data_from_ribbon(mesh, curve.matrix_world, ribbon)
+            for radius in radii:
+                if radius < FLOAT_EPSILON: # radius is 0:
+                    raise RuntimeError(zero_radius_error_message(None, curve))
+            spline_lut = SplineLUT(
+                u_samples, positions, tangents, normals, hash=spline_hash, curve_name=curve_name, )
+            curve_lut_cache.append(spline_lut)
+    
+    CURVE_LUT_CACHE[curve_name] = {}
+    CURVE_LUT_CACHE[curve_name]['hash'] = curve_hash
+    CURVE_LUT_CACHE[curve_name]['spline_LUT'] = curve_lut_cache
+    # remove the mesh, it is no longer needed - we have built a cache
+    # import bpy
+    # bpy.data.meshes.remove(mesh)
+
+def get_from_curve_lut(curve_name, spline_index, bContext) -> SplineLUT:
+    spline_lookup = None
+    try:
+        fill_curve_lut(curve_name=curve_name, bContext=bContext)
+        spline_lookup = CURVE_LUT_CACHE[curve_name]['spline_LUT'][spline_index]
+    except IndexError as e:
+        raise e(f"{self} is attempting to read from a spline in {curve_name} that does not exist."
+                    " Try and reduce the value of Spline Index.")
+    return spline_lookup
+
+
 def matrix_from_head_tail(head, tail, normal=None):
     from mathutils import Vector, Matrix
     if normal is None:
@@ -101,14 +262,7 @@ def get_mesh_from_curve(curve_name : str, execution_id : str, bContext, ribbon=T
             m.name = m_name
         return m
 
-def cleanup_curve(curve_name : str, execution_id : str) -> None:
-        import bpy
-        curve = bpy_object_get_guarded(curve_name)
-        m_name = curve_name+'.'+str(hash(curve.name+'.'+ execution_id))
-        if (mesh := bpy.data.meshes.get(m_name)):
-            bpy.data.meshes.remove(mesh)
-
-def kd_find(node, points, ref_pt, num_points):
+def kd_find(node, points, ref_pt, num_points = 1):
         if num_points == 0:
             raise RuntimeError(f"Cannot find 0 points for {node}")
         from mathutils import kdtree
@@ -326,7 +480,6 @@ class InputColorSetPallete(SimpleInputNode):
             self.parameters[o.name] = o.default_value
         return super().fill_parameters(ui_node)
 
-
 class UtilityCustomProperty(MantisMiscNode):
     __slots__ = ()
     socket_templates = UtilityCustomPropertySockets
@@ -380,80 +533,38 @@ class UtilityMatrixFromCurve(MantisMiscNode):
     def bPrepare(self, bContext = None,):
         from mathutils import Matrix
         import bpy
-        mat = Matrix.Identity(4)
-        curve_name = self.evaluate_input("Curve")
-        curve = bpy_object_get_guarded( curve_name, self)
-        mode = self.evaluate_input("Mode")
+        curve_name = self.evaluate_input("Curve"); mode = self.evaluate_input("Mode")
         # DIVISIONS, FACTOR
-        if not curve:
-            prRed(f"WARN: No curve found for {self}. Using an identity matrix instead.")
-            mat[3][3] = 1.0
-        elif curve.type != "CURVE":
-            prRed(f"WARN: Object {curve.name} is not a curve. Using an identity matrix instead.")
-            mat[3][3] = 1.0
-        else:
-            if bContext is None: bContext = bpy.context # is this wise?
-            m = get_mesh_from_curve(curve.name, self.base_tree.execution_id, bContext)
-            from .utilities.utilities import data_from_ribbon_mesh
-            #
-            num_divisions = self.evaluate_input("Total Divisions")
-            if mode == 'DIVISIONS' and num_divisions <= 0:
-                raise GraphError("The number of divisions in the curve must be 1 or greater.")
-            m_index = self.evaluate_input("Matrix Index")
-            if mode == 'DIVISIONS' and m_index >= num_divisions:
-                prRed(m_index, num_divisions)
-                raise GraphError(f"{self} tried to get a matrix-index greater the total number of divisions."
-                                  "The matrix index starts at 0. You're probably off by +1.")
-            spline_index = self.evaluate_input("Spline Index")
-            if spline_index > len(curve.data.splines)-1:
-                raise GraphError(f"{self} is attempting to read from a spline in {curve.name} that does not exist."
-                                  " Try and reduce the value of Spline Index.")
-            splines_factors = [ [] for i in range (spline_index)]
-            
-            if mode == 'FACTOR':
-                factor = self.evaluate_input('Factor')
-                factor = min(factor, 1 - 0.0001) # to make our fake tangent work
-                factors = [ factor, factor + 0.0001 ]
-                # TODO this is technically not accurate, we want the real tangent
-                # ... but this should be close enough.
-            elif mode == 'FACTOR_WITH_TAIL':
-                factor = self.evaluate_input('Factor')
-                factor = min(factor, 1 - 0.0001) # to make our fake tangent work
-                factors = [ factor,
-                            self.evaluate_input('Factor (Tail)')]
-            else:  # mode == 'DIVISIONS':
-                factors = [1/num_divisions*m_index, 1/num_divisions*(m_index+1)]
-            splines_factors.append(factors)
-            data = data_from_ribbon_mesh(m, splines_factors, curve.matrix_world)
-            if data[spline_index][1][0] < FLOAT_EPSILON: # radius is None:
-                raise RuntimeError(zero_radius_error_message(self, curve))
-            
-            head=data[spline_index][0][0]
-            tail= data[spline_index][0][1]
-            axis = (tail-head).normalized()
-            if axis.length_squared < FLOAT_EPSILON:
-                raise RuntimeError(f"Failed to read the curve {curve.name}.")
-            normal=data[spline_index][2][0]
-            # make sure the normal is perpendicular to the tail
-            from .utilities.utilities import make_perpendicular
-            normal = make_perpendicular(axis, normal)
-            mat = matrix_from_head_tail(head, tail, normal)
-            # this is in world space... let's just convert it back
-            mat.translation = head - curve.location
-            if mode == 'FACTOR':
-                mat[3][3]=1.0 # Set the length
-
-            # TODO HACK TODO
-            # all the nodes should work in world-space, and it should be the responsibility
-            # of the xForm node to convert!
-
-        self.parameters["Matrix"] = mat
+        if bContext is None: bContext = bpy.context # is this wise?
+        spline_index = self.evaluate_input("Spline Index")
+        spline_lookup : SplineLUT = get_from_curve_lut(curve_name, spline_index, bContext)
+        assert(spline_lookup)
+        #
+        num_divisions = self.evaluate_input("Total Divisions")
+        if mode == 'DIVISIONS' and num_divisions <= 0:
+            raise GraphError("The number of divisions in the curve must be 1 or greater.")
+        m_index = self.evaluate_input("Matrix Index")
+        if mode == 'DIVISIONS' and m_index >= num_divisions:
+            prRed(m_index, num_divisions)
+            raise GraphError(f"{self} tried to get a matrix-index greater the total number of divisions."
+                                "The matrix index starts at 0. You're probably off by +1.")
+        if mode == 'FACTOR':
+            factor = self.evaluate_input('Factor')
+            matrix = spline_lookup.matrix_at_u(factor)
+        elif mode == 'FACTOR_WITH_TAIL':
+            factor = self.evaluate_input('Factor')
+            factor_tail = self.evaluate_input('Factor (Tail)')
+            matrix = spline_lookup.matrix_from_start_end_u(factor, factor_tail)
+        else:  # mode == 'DIVISIONS':
+            factor = 1/num_divisions*m_index
+            factor_tail = 1/num_divisions*(m_index+1)
+            matrix = spline_lookup.matrix_from_start_end_u(factor, factor_tail)
+        
+        self.parameters["Matrix"] = matrix
         self.prepared = True
         self.executed = True
 
-    def bFinalize(self, bContext=None):
-        cleanup_curve(self.evaluate_input("Curve"), self.base_tree.execution_id)
-
+#TODO there is no harm in grabbing normal and tangent here, too
 class UtilityPointFromCurve(MantisMiscNode):
     '''Get a point from a curve'''
     __slots__ = ()
@@ -463,29 +574,14 @@ class UtilityPointFromCurve(MantisMiscNode):
     def bPrepare(self, bContext = None,):
         import bpy
         curve_name = self.evaluate_input("Curve")
-        curve = bpy_object_get_guarded( curve_name, self)
-        if not curve:
-            raise RuntimeError(f"No curve found for {self}.")
-        elif curve.type != "CURVE":
-            raise GraphError(f"ERROR: Object {curve.name} is not a curve.")
-        else:
-            if bContext is None: bContext = bpy.context # is this wise?
-            m = get_mesh_from_curve(curve.name, self.base_tree.execution_id, bContext)
-            from .utilities.utilities import data_from_ribbon_mesh
-            #
-            num_divisions = 1
-            spline_index = self.evaluate_input("Spline Index")
-            splines_factors = [ [] for i in range (spline_index)]
-            factors = [self.evaluate_input("Factor")]
-            splines_factors.append(factors)
-            data = data_from_ribbon_mesh(m, splines_factors, curve.matrix_world)
-            p = data[spline_index][0][0] - curve.location
-        self.parameters["Point"] = p
+        if bContext is None: bContext = bpy.context # is this wise?
+        spline_index = self.evaluate_input("Spline Index")
+        spline_lookup : SplineLUT = get_from_curve_lut(curve_name, spline_index, bContext)
+        factor = self.evaluate_input("Factor")
+        position, _tan, _nrm = spline_lookup.data_at_u(factor)
+        self.parameters["Point"] = position
         self.prepared, self.executed = True, True
 
-    def bFinalize(self, bContext=None):
-        cleanup_curve(self.evaluate_input("Curve"), self.base_tree.execution_id)
-
 class UtilityMatricesFromCurve(MantisMiscNode):
     '''Get matrices from a curve'''
     __slots__ = ()
@@ -493,41 +589,15 @@ class UtilityMatricesFromCurve(MantisMiscNode):
     passes=frozenset({'bPrepare', 'bFinalize'})
 
     def bPrepare(self, bContext = None,):
-        import time
-        # start_time = time.time()
-        #
-        from mathutils import Matrix
-        import bpy
-        m = Matrix.Identity(4)
         curve_name = self.evaluate_input("Curve")
-        curve = bpy_object_get_guarded( curve_name, self)
-        if not curve:
-            prRed(f"WARN: No curve found for {self}. Using an identity matrix instead.")
-            m[3][3] = 1.0
-        elif curve.type != "CURVE":
-            prRed(f"WARN: Object {curve.name} is not a curve. Using an identity matrix instead.")
-            m[3][3] = 1.0
-        else:
-            if bContext is None: bContext = bpy.context # is this wise?
-            mesh = get_mesh_from_curve(curve.name, self.base_tree.execution_id, bContext)
-            from .utilities.utilities import data_from_ribbon_mesh
-            num_divisions = self.evaluate_input("Total Divisions")
-            spline_index = self.evaluate_input("Spline Index")
-            splines_factors = [ [] for i in range (spline_index)]
-            factors = [0.0] + [(1/num_divisions*(i+1)) for i in range(num_divisions)]
-            splines_factors.append(factors)
-            data = data_from_ribbon_mesh(mesh, splines_factors, curve.matrix_world)
-            # [spline_index][points,tangents,normals][datapoint_index]
-            from .utilities.utilities import make_perpendicular
-            matrices=[]
-            for i in range(num_divisions):
-                if data[spline_index][1][i] < FLOAT_EPSILON: # radius is None:
-                    raise RuntimeError(zero_radius_error_message(self, curve))
-                m = matrix_from_head_tail (
-                data[spline_index][0][i], data[spline_index][0][i+1],
-                make_perpendicular((data[spline_index][0][i+1]-data[spline_index][0][i]).normalized(), data[spline_index][2][i]),)
-                m.translation = data[spline_index][0][i] - curve.location
-                matrices.append(m)
+        spline_index = self.evaluate_input("Spline Index")
+        import bpy
+        if bContext is None: bContext = bpy.context # is this wise?
+        spline_lookup : SplineLUT = get_from_curve_lut(curve_name, spline_index, bContext)
+    
+        num_divisions = self.evaluate_input("Total Divisions")
+        factors = [0.0] + [(1/num_divisions*(i+1)) for i in range(num_divisions)]
+        matrices = spline_lookup.matrices_from_u_segments(factors)
 
         for link in self.outputs["Matrices"].links:
             for i, m in enumerate(matrices):
@@ -535,23 +605,10 @@ class UtilityMatricesFromCurve(MantisMiscNode):
                 if not (out := self.outputs.get(name)): # reuse them if there are multiple links.
                     out = self.outputs[name] = NodeSocket(name = name, node=self)
                 c = out.connect(link.to_node, link.to_socket)
-                # prOrange(c)
                 self.parameters[name] = m
-                # print (mesh)
             link.die()
+        self.prepared = True; self.executed = True
 
-        self.prepared = True
-        self.executed = True
-        # prGreen(f"Matrices from curves took {time.time() - start_time} seconds.")
-
-    def bFinalize(self, bContext=None):
-        import bpy
-        curve_name = self.evaluate_input("Curve")
-        curve = bpy_object_get_guarded( curve_name, self)
-        m_name = curve.name+'.'+self.base_tree.execution_id
-        if (mesh := bpy.data.meshes.get(m_name)):
-            prGreen(f"Freeing mesh data {m_name}...")
-            bpy.data.meshes.remove(mesh)
 
 class UtilityNumberOfCurveSegments(MantisMiscNode):
     __slots__ = ()
@@ -586,61 +643,27 @@ class UtilityMatrixFromCurveSegment(MantisMiscNode):
     def bPrepare(self, bContext = None,):
         import bpy
         curve_name = self.evaluate_input("Curve")
-        curve = bpy_object_get_guarded( curve_name, self)
-        if not curve:
-            raise RuntimeError(f"No curve found for {self}.")
-        elif curve.type != "CURVE":
-            raise GraphError(f"ERROR: Object {curve.name} is not a curve.")
-        else:
-            if bContext is None: bContext = bpy.context # is this wise?
-            m = get_mesh_from_curve(curve.name, self.base_tree.execution_id, bContext)
-            from .utilities.utilities import data_from_ribbon_mesh
-            # this section is dumb, but it is because the data_from_ribbon_mesh
-            #  function is designed to pull data from many splines at once (for optimization)
-            #  so we have to give it empty splines for each one we skip.
-            # TODO: Refactor this to make it so I can select spline index
-            spline_index = self.evaluate_input("Spline Index")
-            spline = curve.data.splines[spline_index]
-            splines_factors = [ [] for i in range (spline_index)]
-            factors = [0.0]
-            points = spline.bezier_points if spline.type == 'BEZIER' else spline.points
-            total_length=0.0
-            for i in range(len(points)-1):
-                total_length+= (seg_length := (points[i+1].co - points[i].co).length)
-                factors.append(seg_length)
-            prev_length = 0.0
-            for i in range(len(factors)):
-                factors[i] = prev_length+factors[i]/total_length
-                prev_length=factors[i]
-                # Why does this happen? Floating point error?
-                if factors[i]>1.0: factors[i] = 1.0
-            splines_factors.append(factors)
-            #
-            data = data_from_ribbon_mesh(m, splines_factors, curve.matrix_world)
-            segment_index = self.evaluate_input("Segment Index")
-            if data[spline_index][1][segment_index] < FLOAT_EPSILON: # radius is None:
-                raise RuntimeError(zero_radius_error_message(self, curve))
-            head=data[spline_index][0][segment_index]
-            tail= data[spline_index][0][segment_index+1]
-            axis = (tail-head).normalized()
-            normal=data[spline_index][2][segment_index]
-            # make sure the normal is perpendicular to the tail
-            from .utilities.utilities import make_perpendicular
-            normal = make_perpendicular(axis, normal)
-            m = matrix_from_head_tail(head, tail, normal)
-            m.translation = head - curve.location
-            self.parameters["Matrix"] = m
+        curve = bpy_object_get_guarded(curve_name, self)
+        spline_index = self.evaluate_input("Spline Index")
+        if bContext is None: bContext = bpy.context # is this wise?
+        spline_lookup = get_from_curve_lut(curve_name, spline_index, bContext)
+        spline = curve.data.splines[spline_index]
+        factors = []
+        points = spline.bezier_points if spline.type == 'BEZIER' else spline.points
+        total_length=0.0
+        for i in range(len(points)): # points[i].co[:3] because nurbs have W element :/
+            _pt, u = spline_lookup.point_and_u_at_nearest_point_on_curve(points[i].co[:3])
+            factors.append(u)
+        segment_index = self.evaluate_input("Segment Index")
+        m = spline_lookup.matrix_from_start_end_u(factors[segment_index], factors[segment_index+1])
+        self.parameters["Matrix"] = m
         self.prepared, self.executed = True, True
 
-    def bFinalize(self, bContext=None):
-        cleanup_curve(self.evaluate_input("Curve"), self.base_tree.execution_id)
-
 class UtilityGetCurvePoint(MantisMiscNode):
     __slots__ = ()
     socket_templates = GetCurvePointSockets
 
     def bPrepare(self, bContext=None):
-        import bpy
         curve_name = self.evaluate_input("Curve")
         curve = bpy_object_get_guarded( curve_name, self)
         if not curve:
@@ -665,24 +688,12 @@ class UtilityGetNearestFactorOnCurve(MantisMiscNode):
     def bPrepare(self, bContext = None,):
         import bpy
         curve_name = self.evaluate_input("Curve")
-        curve = bpy_object_get_guarded( curve_name, self)
-        if not curve:
-            raise RuntimeError(f"No curve found for {self}.")
-        elif curve.type != "CURVE":
-            raise GraphError(f"ERROR: Object {curve.name} is not a curve.")
-        else:
-            if bContext is None: bContext = bpy.context # is this wise?
-            m = get_mesh_from_curve(curve.name,
-                                    self.base_tree.execution_id,
-                                    bContext, ribbon=False)
-            # this is confusing but I am not re-writing these old functions
-            from .utilities.utilities import FindNearestPointOnWireMesh as nearest_point
-            spline_index = self.evaluate_input("Spline Index")
-            ref_pt = self.evaluate_input("Reference Point")
-            splines_points = [ [] for i in range (spline_index)]
-            splines_points.append([ref_pt])
-            pt =  nearest_point(m, splines_points)[spline_index][0]
-            self.parameters["Factor"] = pt
+        spline_index = self.evaluate_input("Spline Index")
+        if bContext is None: bContext = bpy.context # is this wise?
+        spline_lookup = get_from_curve_lut(curve_name, spline_index, bContext)
+        ref_pt = self.evaluate_input("Reference Point")
+        _closest_point, u = spline_lookup.point_and_u_at_nearest_point_on_curve(ref_pt)
+        self.parameters["Factor"] = u
         self.prepared, self.executed = True, True
 
 class UtilityKDChoosePoint(MantisMiscNode):

+ 136 - 41
utilities/utilities.py

@@ -1137,13 +1137,10 @@ def SetRibbonData(m, ribbon):
     #maybe I can check for closed poly curves here? under what other circumstance
     # will I find the ends of the wire have identical coordinates?
     ribbonData = []
-    tE = ribbon[0].copy(); bE = ribbon[1].copy()# circle = ribbon[2]
-    #
+    tE = ribbon[0].copy(); bE = ribbon[1].copy()
     lengths = RibbonMeshEdgeLengths(m, ribbon)
     lengths.append(0)
     totalLength = sum(lengths)
-    # m.calc_normals() #calculate normals
-    # it appears this has been removed.
     for i, (t, b) in enumerate(zip(tE, bE)):
         ind = old_bad_wrap_that_should_be_refactored( (i + 1), len(tE) - 1 )
         tNext = tE[ind]; bNext = bE[ind]
@@ -1262,43 +1259,39 @@ def mesh_from_curve(crv, context, ribbon=True):
        which will return the correct mesh even with modifiers"""
     import bpy
     m = None
-    bevel = crv.data.bevel_depth
-    extrude = crv.data.extrude
-    offset = crv.data.offset
-    try:
-        if (len(crv.modifiers) > 0):
-            do_unlink = False
-            if (not context.scene.collection.all_objects.get(crv.name)):
-                context.collection.objects.link(crv) # i guess this forces the dg to update it?
-                do_unlink = True
-            dg = context.view_layer.depsgraph
-            # just gonna modify it for now lol
-            if ribbon:
-                EnsureCurveIsRibbon(crv)
-            else:
-                crv.data.bevel_depth=0
-                crv.data.extrude=0
-                crv.data.offset=0
-            # try:
-            dg.update()
-            mOb = crv.evaluated_get(dg)
-            m = bpy.data.meshes.new_from_object(mOb)
-            m.name=crv.data.name+'_mesh'
-            if (do_unlink):
-                context.collection.objects.unlink(crv)
-        else: # (ಥ﹏ಥ) why can't I just use this !
-            # for now I will just do it like this
-            if ribbon:
-                EnsureCurveIsRibbon(crv)
-            else:
-                crv.data.bevel_depth=0
-                crv.data.extrude=0
-                crv.data.offset=0
-            m = bpy.data.meshes.new_from_object(crv)
-    finally:
-        crv.data.bevel_depth = bevel
-        crv.data.extrude = extrude
-        crv.data.offset = offset
+    crv_ob = crv.copy()
+    crv_data_copy = crv.data.copy()
+    crv_ob.data = crv_data_copy
+    if (len(crv_ob.modifiers) > 0):
+        do_unlink = False
+        if (not context.scene.collection.all_objects.get(crv_ob.name)):
+            context.collection.objects.link(crv_ob) # i guess this forces the dg to update it?
+            do_unlink = True
+        dg = context.view_layer.depsgraph
+        # just gonna modify it for now lol
+        if ribbon:
+            EnsureCurveIsRibbon(crv_ob)
+        else:
+            crv_ob.data.bevel_depth=0
+            crv_ob.data.extrude=0
+            crv_ob.data.offset=0
+        # try:
+        dg.update()
+        mOb = crv_ob.evaluated_get(dg)
+        m = bpy.data.meshes.new_from_object(mOb)
+        m.name=crv_ob.data.name+'_mesh'
+        if (do_unlink):
+            context.collection.objects.unlink(crv_ob)
+    else: # (ಥ﹏ಥ) why can't I just use this !
+        # for now I will just do it like this
+        if ribbon:
+            EnsureCurveIsRibbon(crv_ob)
+        else:
+            crv_ob.data.bevel_depth=0
+            crv_ob.data.extrude=0
+            crv_ob.data.offset=0
+        m = bpy.data.meshes.new_from_object(crv_ob)
+    bpy.data.objects.remove(crv_ob)
     return m
 
 def DetectRibbon(f, bm, skipMe):
@@ -1459,7 +1452,109 @@ def data_from_ribbon_mesh(m, factorsList, mat, ribbons = None, fReport = None):
         ret.append( (points, widths, normals) )
     return ret # this is a list of tuples containing three lists
 
+from mathutils import Vector
+
+# Used for filling the Curve LUTimport numpy as np
+import numpy as np
+
+def all_data_from_ribbon(mesh, mat, ribbon):
+    top_indices, bottom_indices, is_closed = ribbon
+    top_indices = np.asarray(top_indices, dtype=np.intp)
+    bottom_indices = np.asarray(bottom_indices, dtype=np.intp)
+    count = len(top_indices)
+    assert count >= 2, "this should never happen, report this as a bug"
+    # we'll use numpy to calculate the positions and normals because it is fast.
+    vertex_count = len(mesh.vertices)
+    coordinates = np.empty(vertex_count * 3, dtype=np.float64)
+    mesh.vertices.foreach_get("co", coordinates)
+    coordinates = coordinates.reshape((-1, 3))
+    vertex_normals = np.empty(vertex_count * 3, dtype=np.float64)
+    mesh.vertices.foreach_get("normal", vertex_normals)
+    vertex_normals = vertex_normals.reshape((-1, 3))
+    
+    top_local = coordinates[top_indices]; bottom_local = coordinates[bottom_indices]
+    # similarly we have to make the world matrix a numpy array
+    transform = np.asarray(mat, dtype=np.float64)
+    basis = transform[:3, :3]; translation = transform[:3, 3]
+    #.T is transpose because this numpy array expects row-major
+    top_positions = top_local @ basis.T + translation
+    bottom_positions = bottom_local @ basis.T + translation
+    positions = (top_positions + bottom_positions) * 0.5
+    radii = np.linalg.norm(top_positions - bottom_positions, axis=1) * 0.5
+
+    # transform the potisions by the object's matrix, mat
+    # and the normals need a slightly different transformation
+    normal_transform = np.linalg.inv(basis).T
+    local_normals = ( vertex_normals[top_indices] + vertex_normals[bottom_indices] )
+    normals = local_normals @ normal_transform.T
+    normal_lengths = np.linalg.norm(normals, axis=1)
+    # Normalize the normals by dividing them by the lengths. avoid dividing by 0
+    normals = np.divide(
+        normals,
+        normal_lengths[:, None],
+        out=np.zeros_like(normals),
+        where=normal_lengths[:, None] != 0,
+    )
+
+    # all of this incoming/outgoing subtraction stuff is doing the following:
+    # take the current vertex positon and get the tangent vector of each segment
+    # it is a part of, then average them
+    if is_closed: # have to adjust it back, I don't really know if this works
+        incoming = positions - np.roll(positions, 1, axis=0)
+        outgoing = np.roll(positions, -1, axis=0) - positions
+    else:
+        incoming = np.zeros_like(positions); outgoing = np.zeros_like(positions)
+        incoming[1:] = positions[1:] - positions[:-1]
+        outgoing[:-1] = positions[1:] - positions[:-1]
+        incoming[0] = outgoing[0]
+        outgoing[-1] = incoming[-1]
+
+    incoming_lengths = np.linalg.norm(incoming, axis=1)
+    outgoing_lengths = np.linalg.norm(outgoing, axis=1)
+    # the segments may be different lengths so we have to normalize them
+    incoming = np.divide(
+        incoming,
+        incoming_lengths[:, None],
+        out=np.zeros_like(incoming),
+        where=incoming_lengths[:, None] != 0,
+    )
+    outgoing = np.divide(
+        outgoing,
+        outgoing_lengths[:, None],
+        out=np.zeros_like(outgoing),
+        where=outgoing_lengths[:, None] != 0,
+    )
+    # then we add them together and normalize again to average them
+    tangents = incoming + outgoing
+    tangent_lengths = np.linalg.norm(tangents, axis=1)
+    tangents = np.divide(
+        tangents,
+        tangent_lengths[:, None],
+        out=np.zeros_like(tangents),
+        where=tangent_lengths[:, None] != 0,
+    )
+    # calcualte the U value of the samples based on the length of the spline
+    segment_lengths = np.linalg.norm(positions[1:] - positions[:-1], axis=1)
+    if is_closed:
+        closing_length = np.linalg.norm(positions[0] - positions[-1])
+        total_length = segment_lengths.sum() + closing_length
+    else:
+        total_length = segment_lengths.sum()
 
+    if total_length:
+        u_samples = np.concatenate(([0.0], np.cumsum(segment_lengths))) / total_length
+    else:
+        u_samples = np.zeros(count, dtype=np.float64)
+    
+    # now we have to convert the values back to values Blender uses
+    # TODO eventually I want to port everything to numpy even tho it is ugly
+    u_samples = tuple(u_samples)
+    radii = tuple(radii)
+    from mathutils import Vector
+    positions = [Vector(elem) for elem in positions]
+    normals = [Vector(elem) for elem in normals]
+    tangents = [Vector(elem) for elem in tangents]
+    return u_samples, positions, normals, tangents, radii
 
 
 #This bisection search is generic, and it searches based on the

+ 94 - 74
xForm_nodes.py

@@ -720,34 +720,62 @@ class xFormCurvePin(xFormNode):
             additional_parameters={"Matrix":None})
         setup_custom_property_inputs_outputs(self)
 
-    def prep_driver_values(self, constraint):
-        enums =  ( None,
-                   ("FORWARD_X", "FORWARD_Y", "FORWARD_Z", "TRACK_NEGATIVE_X", "TRACK_NEGATIVE_Y", "TRACK_NEGATIVE_Z"),
-                   ("UP_X", "UP_Y", "UP_Z"),
-                 )
-        for i, socket_name in enumerate(("Curve Pin Factor", "Forward Axis","Up Axis",)):
-            if self.inputs.get(socket_name) is None: continue 
-            enum = enums[i]
-            for template in self.socket_templates:
-                if template.name == socket_name: break
-            prop_amount = find_driven_default_value(self, socket_name)
-            # after all it may not be driven so it is probably a string if it is one of the axis properties.
-            if isinstance(prop_amount, (int, float)) and enum: # it is actually driving an enum value instead...
-                for i, enum_val in enumerate(enum):
-                    # check if the prop amount is in the current int as the enum val
-                    if i+1 > prop_amount: break
-                prop_amount = enum_val            
-            try:
-                setattr(constraint, template.blender_property, prop_amount )
-            except TypeError:
-                setattr(constraint, template.blender_property, template.default_value )
+    def get_matrix_from_spline(self, spline_lookup):
+        matrix = spline_lookup.matrix_at_u(self.parameters["Curve Pin Factor"])
+        track_axis = self.parameters["Forward Axis"]; up_axis = self.parameters["Up Axis"]
+        # Copy the original basis before changing anything.
+        from mathutils import Vector
+        tangent = Vector(matrix.col[1][:3]); normal = Vector(matrix.col[2][:3])
+        # I have to take the Z axis and invert it because of the way Mantis calculates the normal
+        # using the ribbon mesh rather which extrudes along the binormal
+        # this is bad but it is too late to change this now, oh well
+        normal *= -1 # I have checked this with every combination and it works
+        track_sign = -1 if track_axis.startswith("TRACK_NEGATIVE_") else 1
+        track_axis = track_axis[-1]; up_axis = up_axis[-1]
+        axis_index = {"X": 0, "Y": 1, "Z": 2} #  there is probably a less stupid way to do this
+        track_index = axis_index[track_axis]; up_index = axis_index[up_axis]
+        if track_index == up_index: raise ValueError("Track and up_axis axes must be different")
+        # Assign the requested axes.
+        cols = [None, None, None]
+        cols[track_index] = tangent * track_sign; cols[up_index] = normal
+        # Fill the remaining axis to make a right-handed basis.
+        # the bitanget calculation is order dependent if you want to stay right-handed
+        if track_index != 0 and up_index != 0:   cols[0] = cols[1].cross(cols[2])
+        elif track_index != 1 and up_index != 1: cols[1] = cols[2].cross(cols[0])
+        else:                                    cols[2] = cols[0].cross(cols[1])
+        matrix.col[0].xyz = cols[0]; matrix.col[1].xyz = cols[1]
+        matrix.col[2].xyz = cols[2]
+        return matrix
+
+    def generate_constraints(self, ob, curve):
+        # we'll limit all the transforms so we can parent it without
+        # having to deal with the weird curve parenting (path-animation)
+        c1 = ob.constraints.new("LIMIT_LOCATION")
+        for max_min in ['max','min']:
+            for axis in "xyz":
+                setattr(c1, "use_"+max_min+"_"+axis, True)
+                setattr(c1, max_min+"_"+axis, 0.0)
+        c2 = ob.constraints.new("LIMIT_ROTATION")
+        for axis in "xyz":
+            setattr(c2, "use_limit_"+axis, True)
+            setattr(c2, max_min+"_"+axis, 0.0)
+        c3 = ob.constraints.new("LIMIT_SCALE")
+        for max_min in ['max','min']:
+            for axis in "xyz":
+                setattr(c3, "use_"+max_min+"_"+axis, True)
+                setattr(c3, max_min+"_"+axis, 1.0)
+        c4 = ob.constraints.new("FOLLOW_PATH")
+        c4.target = curve
+        c4.use_fixed_location = True
+        c4.use_curve_radius = True
+        c4.use_curve_follow = True
+        c4.name = "Curve Pin"
+        return (c1, c2, c3, c4,)
 
     def bPrepare(self, bContext = None,):
-        from bpy import data
-
-        if not bContext: # lol
-            import bpy
-            bContext = bpy.context
+        from bpy import data, context
+        if not bContext: bContext = context # HACK is this wise?
+        # Generate the empty
         ob = data.objects.get(self.evaluate_input("Name"))
         if not ob:
             ob = data.objects.new(self.evaluate_input("Name"), None)
@@ -757,72 +785,64 @@ class xFormCurvePin(xFormNode):
             ob.lock_rotation_w = True
             ob.empty_display_type = 'CONE'
             ob.empty_display_size = 0.10
-
-        self.bObject = ob
-        self.bRegisterObject('OBJECT', self.bObject)
-        reset_object_data(ob)
-
+        self.bObject = ob; self.bRegisterObject('OBJECT', ob); reset_object_data(ob)
+        # Get the curve and check it
         node_line = trace_single_line(self, "Parent Curve")[0][1:] # slice excludes self
         for other_node in node_line:
-            if other_node.node_type == 'XFORM':
-                break
+            if other_node.node_type == 'XFORM': break
         else:
             raise GraphError(f"ERROR: {self} is not connected to a parent curve")
         if isinstance(other_node, (xFormArmature, xFormBone, xFormObjectInstance,)):
             raise GraphError(f"ERROR: {self} must be connected to curve,"
                               " not {other_node.__class__.__name__}")
-        curve=other_node.bGetObject()
+        curve=other_node.bGetObject(); curve_name = curve.name
         if curve.type != 'CURVE':
             raise GraphError(f"ERROR: {self} must be connected to curve,"
                               " not {curve.type}")
-        # we'll limit all the transforms so we can parent it
-        #  because it is annoying to have a cluttered outliner.
-        #
-        # always do this so that everything stays consistent.
-        spline_index = self.evaluate_input("Spline Index")
         from .utilities.utilities import get_extracted_spline_object
+        spline_index = self.evaluate_input("Spline Index")
         curve = get_extracted_spline_object(curve, spline_index, self.mContext)
-        # Link to Scene:
-        for link_me in [ob, curve]:
+        for link_me in [ob, curve]: # Link everything to the scene:
             if (link_me.name not in bContext.view_layer.active_layer_collection.collection.objects):
                 bContext.view_layer.active_layer_collection.collection.objects.link(link_me)
 
-        c = ob.constraints.new("LIMIT_LOCATION")
-        for max_min in ['max','min']:
-            for axis in "xyz":
-                setattr(c, "use_"+max_min+"_"+axis, True)
-                setattr(c, max_min+"_"+axis, 0.0)
-        c = ob.constraints.new("LIMIT_ROTATION")
-        for axis in "xyz":
-            setattr(c, "use_limit_"+axis, True)
-            setattr(c, max_min+"_"+axis, 0.0)
-        c = ob.constraints.new("LIMIT_SCALE")
-        for max_min in ['max','min']:
-            for axis in "xyz":
-                setattr(c, "use_"+max_min+"_"+axis, True)
-                setattr(c, max_min+"_"+axis, 1.0)
-        c = ob.constraints.new("FOLLOW_PATH")
-        c.target = curve
-        c.use_fixed_location = True
-        c.use_curve_radius = True
-        c.use_curve_follow = True
-        c.name = "Curve Pin"
-
-        constraint_props_sockets = self.gen_property_socket_map( prop_owner_group='constraints')
-        props_sockets = self.gen_property_socket_map( prop_owner_group='object')
+        constraints = self.generate_constraints(ob, curve); c = constraints[-1]
+        props_sockets = self.gen_property_socket_map()
         b_objects = {"constraints":c, "object":self.bObject}
         evaluate_sockets(self, b_objects, props_sockets)
-        # this isn't usually run on xForm nodes so for now I need to set the
-        #   driver's default values manually if I want a matrix now.
-        # because the drivers may not have initialized yet.
-        self.prep_driver_values(c)
-        # now if all goes well... the matrix will be correct.
-        # NOTE: this dg.update() makes this a very expensive node
-        dg = bContext.view_layer.depsgraph
-        dg.update()
-        # and the matrix should be correct now - copy because it may be modified
-        self.parameters['Matrix'] = ob.matrix_world.copy()
+
+        # Find the default values to set even if the properties are drive:
+        enums =  ( None,
+                ("FORWARD_X", "FORWARD_Y", "FORWARD_Z", "TRACK_NEGATIVE_X", "TRACK_NEGATIVE_Y", "TRACK_NEGATIVE_Z"),
+                ("UP_X", "UP_Y", "UP_Z"),
+                )
+        for i, socket_name in enumerate(("Curve Pin Factor", "Forward Axis","Up Axis",)):
+            if self.inputs.get(socket_name) is None: continue 
+            enum = enums[i]
+            for template in self.socket_templates:
+                if template.name == socket_name: break
+            prop_amount = find_driven_default_value(self, socket_name)
+            # after all it may not be driven so it is probably a string if it is one of the axis properties.
+            if isinstance(prop_amount, (int, float)) and enum: # it is actually driving an enum value instead...
+                for i, enum_val in enumerate(enum):
+                    # check if the prop amount is in the current int as the enum val
+                    if i+1 > prop_amount: break
+                prop_amount = enum_val
+            self.parameters[socket_name] = prop_amount
+            # Explicitly set the constraint attributes
+            setattr(c, template.blender_property, prop_amount)
+
+        # Now set the matrix - since this may provide dependencies to other nodes
+        from .misc_nodes import get_from_curve_lut
+        spline_lookup = get_from_curve_lut(curve_name, spline_index, bContext)
+        matrix = self.get_matrix_from_spline(spline_lookup)
+        # HACK: this is giving a local space matrix, and that's dumb
+        # TODO: Mantis should handle coordinate space conversion automatically
+        self.parameters['Matrix'] = matrix
         ob.parent=curve
+        # NOTE: Blender does weird things with curve parenting when there is Path Animation
+        #  and it sets the inverse parent matrix based on the evaluated matrix from the point
+        #  on the curve set by path animation. (That's why we need all the WS limit constraints)
         print( wrapGreen("Created Curve Pin: ") + wrapOrange(self.bObject.name) )
 
         parent_xForm_info = get_parent_xForm_info(self)