diff --git a/libs/kimath/CMakeLists.txt b/libs/kimath/CMakeLists.txt
index ff22ec2d27..991aa0c7bb 100644
--- a/libs/kimath/CMakeLists.txt
+++ b/libs/kimath/CMakeLists.txt
@@ -31,6 +31,7 @@ set( KIMATH_SRCS
     src/geometry/shape_rect.cpp
     src/geometry/shape_compound.cpp
     src/geometry/shape_segment.cpp
+    src/geometry/vector_utils.cpp
     src/geometry/vertex_set.cpp
 
 
diff --git a/libs/kimath/include/geometry/vector_utils.h b/libs/kimath/include/geometry/vector_utils.h
new file mode 100644
index 0000000000..c89ca04273
--- /dev/null
+++ b/libs/kimath/include/geometry/vector_utils.h
@@ -0,0 +1,118 @@
+/*
+ * This program source code file is part of KiCad, a free EDA CAD application.
+ *
+ * Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the
+ * Free Software Foundation, either version 3 of the License, or (at your
+ * option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * 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, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <math/vector2d.h>
+
+class SEG;
+
+namespace KIGEOM
+{
+
+/**
+ * @file vector_utils.h
+ *
+ * Supplemental functions for working with vectors and
+ * objects that interact with vectors.
+ */
+
+/*
+ * Determine if a point is in a given direction from another point.
+ *
+ * This returns true if the vector from aFrom to aPoint is within
+ * 90 degrees of aDirection.
+ *
+ *     ------> aDirection
+ *
+ *     /-- aFrom
+ *    O                /-- aPoint
+ *                    O
+ *
+ * If the point is perpendicular to the direction, it is considered
+ * to NOT be in the direction (e.g. both the direction and the
+ * reversed direction would return false).
+ *
+ * @param aPoint The point to test.
+ * @param aDirection The direction vector.
+ * @param aFrom The point to test from.
+ *
+ * @return true if the point is in the direction.
+ */
+template <typename T>
+bool PointIsInDirection( const VECTOR2<T>& aPoint, const VECTOR2<T>& aDirection,
+                         const VECTOR2<T>& aFrom )
+{
+    return ( aPoint - aFrom ).Dot( aDirection ) > 0;
+}
+
+
+/**
+ * Determine if a segment's vector is within 90 degrees of a given direction.
+ */
+bool SegIsInDirection( const SEG& aSeg, const VECTOR2I& aDirection );
+
+
+/**
+ * Determine if a point projects onto a segment.
+ *
+ *       /--- projects      /--- does not project
+ *      o                  o
+ *      |                  |
+ * |<------------>|        x
+ *      aSeg
+ */
+bool PointProjectsOntoSegment( const VECTOR2I& aPoint, const SEG& aSeg );
+
+
+/**
+ * Get the ratio of the vector to a point from the segment's start,
+ * compared to the segment's length.
+ *        /--- aPoint
+ *  A<---+-------->B  <-- Length L
+ *  |    |
+ * >|----|<-- Length R
+ *
+ * The point doesn't have to lie on the segment.
+ */
+double GetLengthRatioFromStart( const VECTOR2I& aPoint, const SEG& aSeg );
+
+/**
+ * Get the ratio of the vector to a point projected onto a segment
+ * from the start, relative to the segment's length.
+ *
+ *       /--- projects
+ *      o
+ *      |
+ *  A<---+-------->B  <-- Length L
+ *  |    |
+ * >|----|<-- Length R
+ *
+ * The ratio is R / L. IF 0, the point is at A. If 1, the point is at B.
+ * It assumes the point projects onto the segment.
+ */
+double GetProjectedPointLengthRatio( const VECTOR2I& aPoint, const SEG& aSeg );
+
+
+/**
+ * Get the nearest end of a segment to a point.
+ *
+ * If equidistant, the start point is returned.
+ */
+const VECTOR2I& GetNearestEndpoint( const SEG& aSeg, const VECTOR2I& aPoint );
+
+} // namespace KIGEOM
diff --git a/libs/kimath/src/geometry/vector_utils.cpp b/libs/kimath/src/geometry/vector_utils.cpp
new file mode 100644
index 0000000000..0277ff0c07
--- /dev/null
+++ b/libs/kimath/src/geometry/vector_utils.cpp
@@ -0,0 +1,67 @@
+/*
+ * This program source code file is part of KiCad, a free EDA CAD application.
+ *
+ * Copyright (C) 2024 KiCad Developers, see AUTHORS.txt for contributors.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the
+ * Free Software Foundation, either version 3 of the License, or (at your
+ * option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * 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, see <http://www.gnu.org/licenses/>.
+ */
+
+#include "geometry/vector_utils.h"
+
+#include <geometry/seg.h>
+
+using namespace KIGEOM;
+
+bool KIGEOM::SegIsInDirection( const SEG& aSeg, const VECTOR2I& aDirection )
+{
+    return PointIsInDirection( aSeg.B, aDirection, aSeg.A );
+};
+
+
+bool KIGEOM::PointProjectsOntoSegment( const VECTOR2I& aPoint, const SEG& aSeg )
+{
+    // SEG::NearestPoint returns the end points if the projection is
+    // outside the segment
+    const VECTOR2I projected = aSeg.NearestPoint( aPoint );
+    return projected != aSeg.A && projected != aSeg.B;
+}
+
+
+double KIGEOM::GetLengthRatioFromStart( const VECTOR2I& aPoint, const SEG& aSeg )
+{
+    const double length = aSeg.Length();
+    const double projected_length = ( aPoint - aSeg.A ).EuclideanNorm();
+    return projected_length / length;
+}
+
+
+double KIGEOM::GetProjectedPointLengthRatio( const VECTOR2I& aPoint, const SEG& aSeg )
+{
+    const VECTOR2I projected = aSeg.NearestPoint( aPoint );
+
+    if( projected == aSeg.A )
+        return 0.0;
+    if( projected == aSeg.B )
+        return 1.0;
+
+    return GetLengthRatioFromStart( projected, aSeg );
+}
+
+
+const VECTOR2I& KIGEOM::GetNearestEndpoint( const SEG& aSeg, const VECTOR2I& aPoint )
+{
+    const double distToCBStart = aSeg.A.Distance( aPoint );
+    const double distToCBEnd = aSeg.B.Distance( aPoint );
+    return ( distToCBStart <= distToCBEnd ) ? aSeg.A : aSeg.B;
+}
\ No newline at end of file
diff --git a/pcbnew/tools/pcb_point_editor.cpp b/pcbnew/tools/pcb_point_editor.cpp
index f894b9ca05..daa468b3ad 100644
--- a/pcbnew/tools/pcb_point_editor.cpp
+++ b/pcbnew/tools/pcb_point_editor.cpp
@@ -33,6 +33,7 @@ using namespace std::placeholders;
 #include <view/view_controls.h>
 #include <gal/graphics_abstraction_layer.h>
 #include <geometry/seg.h>
+#include <geometry/vector_utils.h>
 #include <confirm.h>
 #include <tools/pcb_actions.h>
 #include <tools/pcb_selection_tool.h>
@@ -1277,20 +1278,21 @@ private:
         const EDA_ANGLE rotation = oldAngle - newAngle;
 
         // There are two modes - when the text is between the crossbar points, and when it's not.
-        if( !textIsOverCrossBar( m_oldCrossBar, m_originalTextPos ) )
+        if( !KIGEOM::PointProjectsOntoSegment( m_originalTextPos, m_oldCrossBar ) )
         {
             VECTOR2I rotTextOffsetFromCbCenter = m_originalTextPos - m_oldCrossBar.Center();
             RotatePoint( rotTextOffsetFromCbCenter, rotation );
 
             VECTOR2I rotTextOffsetFromCbEnd =
-                    getTextOffsetFromCrossbarNearestEnd( m_oldCrossBar, m_originalTextPos );
+                    m_originalTextPos
+                    - KIGEOM::GetNearestEndpoint( m_oldCrossBar, m_originalTextPos );
             RotatePoint( rotTextOffsetFromCbEnd, rotation );
 
             // Which of the two crossbar points is now in the right direction? They could be swapped over now.
             // If zero-length, doesn't matter, they're the same thing
             const bool startIsInOffsetDirection =
-                    pointIsInDirection( m_dimension.GetCrossbarStart(), rotTextOffsetFromCbCenter,
-                                        newCrossBar.Center() );
+                    KIGEOM::PointIsInDirection( m_dimension.GetCrossbarStart(),
+                                                rotTextOffsetFromCbCenter, newCrossBar.Center() );
 
             const VECTOR2I& newCbRefPt = startIsInOffsetDirection ? m_dimension.GetCrossbarStart()
                                                                   : m_dimension.GetCrossbarEnd();
@@ -1303,8 +1305,8 @@ private:
         // good place for it. Keep it the same distance from the crossbar line, but rotated as needed.
 
         const VECTOR2I origTextPointProjected = m_oldCrossBar.NearestPoint( m_originalTextPos );
-        const double   oldRatio = ( origTextPointProjected - m_oldCrossBar.A ).EuclideanNorm()
-                                / double( m_oldCrossBar.Length() );
+        const double   oldRatio =
+                KIGEOM::GetLengthRatioFromStart( origTextPointProjected, m_oldCrossBar );
 
         // Perpendicular from the crossbar line to the text position
         // We need to keep this length constant
@@ -1315,30 +1317,6 @@ private:
         return newProjected + rotCbNormalToText;
     }
 
-    static bool pointIsInDirection( const VECTOR2I& aPoint, const VECTOR2I& aDirection,
-                                    const VECTOR2I& aFrom )
-    {
-        return ( aPoint - aFrom ).Dot( aDirection ) > 0;
-    };
-
-    static bool textIsOverCrossBar( const SEG& aCrossbar, const VECTOR2I& aTextPos )
-    {
-        const VECTOR2I projected = aCrossbar.NearestPoint( aTextPos );
-        return projected != aCrossbar.A && projected != aCrossbar.B;
-    }
-
-    static VECTOR2I getTextOffsetFromCrossbarNearestEnd( const SEG&      aCrossbar,
-                                                         const VECTOR2I& aTextPos )
-    {
-        const int distToCBStart = aCrossbar.A.Distance( aTextPos );
-        const int distToCBEnd = aCrossbar.B.Distance( aTextPos );
-
-        const bool isNearerStart = distToCBStart < distToCBEnd;
-
-        // This is the offset of the text from the nearer crossbar point
-        return aTextPos - ( isNearerStart ? aCrossbar.A : aCrossbar.B );
-    }
-
     PCB_DIM_ALIGNED& m_dimension;
     const VECTOR2I   m_originalTextPos;
     const SEG        m_oldCrossBar;
diff --git a/qa/tests/libs/kimath/CMakeLists.txt b/qa/tests/libs/kimath/CMakeLists.txt
index b05ac88851..82d03be9bd 100644
--- a/qa/tests/libs/kimath/CMakeLists.txt
+++ b/qa/tests/libs/kimath/CMakeLists.txt
@@ -46,6 +46,7 @@ set( QA_KIMATH_SRCS
     geometry/test_shape_poly_set_iterator.cpp
     geometry/test_shape_line_chain.cpp
     geometry/test_shape_line_chain_collision.cpp
+    geometry/test_vector_utils.cpp
 
     math/test_box2.cpp
     math/test_matrix3x3.cpp
diff --git a/qa/tests/libs/kimath/geometry/test_vector_utils.cpp b/qa/tests/libs/kimath/geometry/test_vector_utils.cpp
new file mode 100644
index 0000000000..1418de0669
--- /dev/null
+++ b/qa/tests/libs/kimath/geometry/test_vector_utils.cpp
@@ -0,0 +1,106 @@
+/*
+ * This program source code file is part of KiCad, a free EDA CAD application.
+ *
+ * Copyright (C) 2024 KiCad Developers, see AUTHORS.TXT for contributors.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ */
+
+#include <qa_utils/wx_utils/unit_test_utils.h>
+
+#include <geometry/vector_utils.h>
+#include <geometry/seg.h>
+
+
+BOOST_AUTO_TEST_SUITE( VectorUtils )
+
+BOOST_AUTO_TEST_CASE( PointIsInDirection )
+{
+    const VECTOR2I p0( 1000, 1000 );
+
+    const VECTOR2I ne( 100, 100 );
+    const VECTOR2I n( 0, 100 );
+    const VECTOR2I s( 0, -100 );
+
+    // To the east, so not directly inline with ne from p0
+    const VECTOR2I p1_east = p0 + VECTOR2I( 1000, 0 );
+    const VECTOR2I p1_west = p0 - VECTOR2I( 1000, 0 );
+
+    BOOST_TEST( KIGEOM::PointIsInDirection( p1_east, ne, p0 ) );
+    BOOST_TEST( !KIGEOM::PointIsInDirection( p1_west, ne, p0 ) );
+
+    // Test the perpendicular corner case
+
+    // Points on both sides are not in the direction
+    BOOST_TEST( !KIGEOM::PointIsInDirection( p1_east, n, p0 ) );
+    BOOST_TEST( !KIGEOM::PointIsInDirection( p1_west, n, p0 ) );
+
+    // And they're also not in the opposite direction
+    BOOST_TEST( !KIGEOM::PointIsInDirection( p1_east, s, p0 ) );
+    BOOST_TEST( !KIGEOM::PointIsInDirection( p1_west, s, p0 ) );
+}
+
+BOOST_AUTO_TEST_CASE( ProjectsOntoSeg )
+{
+    const SEG seg( VECTOR2I( 0, 0 ), VECTOR2I( 100, 0 ) );
+
+    BOOST_TEST( KIGEOM::PointProjectsOntoSegment( VECTOR2I( 50, 1000 ), seg ) );
+    BOOST_TEST( !KIGEOM::PointProjectsOntoSegment( VECTOR2I( 150, 1000 ), seg ) );
+}
+
+BOOST_AUTO_TEST_CASE( LengthRatio )
+{
+    const SEG seg( VECTOR2I( 0, 0 ), VECTOR2I( 100, 0 ) );
+
+    BOOST_TEST( KIGEOM::GetLengthRatioFromStart( VECTOR2I( 0, 0 ), seg ) == 0.0 );
+    BOOST_TEST( KIGEOM::GetLengthRatioFromStart( VECTOR2I( 100, 0 ), seg ) == 1.0 );
+    BOOST_TEST( KIGEOM::GetLengthRatioFromStart( VECTOR2I( 50, 0 ), seg ) == 0.5 );
+
+    // Points not on the segment also work
+    BOOST_CHECK_CLOSE( KIGEOM::GetLengthRatioFromStart( VECTOR2I( 0, 100 ), seg ), 1.0, 0.0001 );
+    BOOST_CHECK_CLOSE( KIGEOM::GetLengthRatioFromStart( VECTOR2I( 0, 50 ), seg ), 0.5, 0.0001 );
+}
+
+BOOST_AUTO_TEST_CASE( NearestEndpoint )
+{
+    struct PtCase
+    {
+        VECTOR2I Point;
+        bool     ExpectedStart;
+    };
+
+    const SEG seg( VECTOR2I( 0, 0 ), VECTOR2I( 100, 0 ) );
+
+    const std::vector<PtCase> cases{
+        { { -100, 0 }, true },
+        { { 0, 0 }, true },
+        { { 0, 50 }, true },
+        // Make sure the tie breaks predictably
+        // Equidistant points -> start
+        { { 50, 0 }, true },
+        { { 50, 50 }, true },
+        { { 50, -50 }, true },
+        // End points
+        { { 200, 0 }, false },
+        { { 100, 0 }, false },
+        { { 100, 50 }, false },
+    };
+
+    for( const PtCase& pc : cases )
+    {
+        BOOST_TEST_INFO( "Point: " << pc.Point );
+        BOOST_TEST( KIGEOM::GetNearestEndpoint( seg, pc.Point )
+                    == ( pc.ExpectedStart ? seg.A : seg.B ) );
+    }
+}
+
+BOOST_AUTO_TEST_SUITE_END()
\ No newline at end of file