You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by er...@apache.org on 2020/02/23 17:02:45 UTC

[commons-geometry] 01/04: GEOMETRY-89: adding linear, linearTranspose, and normalTransform methods to AffineTransformMatrixXD

This is an automated email from the ASF dual-hosted git repository.

erans pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-geometry.git

commit d39700591b7e1e2cdad20e6c5a5094a43cf6c54c
Author: Matt Juntunen <ma...@hotmail.com>
AuthorDate: Sat Feb 22 11:57:53 2020 -0500

    GEOMETRY-89: adding linear, linearTranspose, and normalTransform methods to AffineTransformMatrixXD
---
 .../euclidean/AbstractAffineTransformMatrix.java   |  38 +++++++-
 .../euclidean/oned/AffineTransformMatrix1D.java    |  32 +++++-
 .../euclidean/threed/AffineTransformMatrix3D.java  |  40 +++++++-
 .../euclidean/twod/AffineTransformMatrix2D.java    |  36 ++++++-
 .../oned/AffineTransformMatrix1DTest.java          |  66 +++++++++++++
 .../threed/AffineTransformMatrix3DTest.java        | 107 +++++++++++++++++++++
 .../twod/AffineTransformMatrix2DTest.java          |  95 ++++++++++++++++++
 7 files changed, 407 insertions(+), 7 deletions(-)

diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractAffineTransformMatrix.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractAffineTransformMatrix.java
index 9674517..58e1d5a 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractAffineTransformMatrix.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractAffineTransformMatrix.java
@@ -19,8 +19,11 @@ package org.apache.commons.geometry.euclidean;
 /** Base class for affine transform matrices in Euclidean space.
  *
  * @param <V> Vector/point implementation type defining the space.
+ * @param <M> Matrix transform implementation type.
  */
-public abstract class AbstractAffineTransformMatrix<V extends EuclideanVector<V>>
+public abstract class AbstractAffineTransformMatrix<
+        V extends EuclideanVector<V>,
+        M extends AbstractAffineTransformMatrix<V, M>>
     implements EuclideanTransform<V> {
 
     /** Apply this transform to the given vector, ignoring translations and normalizing the
@@ -40,6 +43,39 @@ public abstract class AbstractAffineTransformMatrix<V extends EuclideanVector<V>
     public abstract double determinant();
 
     /** {@inheritDoc}
+     * @throws IllegalStateException if the matrix cannot be inverted
+     */
+    @Override
+    public abstract M inverse();
+
+    /** Return a matrix containing only the linear portion of this transform.
+     * The returned instance contains the same matrix elements as this instance
+     * but with the translation component set to zero.
+     * @return a matrix containing only the linear portion of this transform
+     */
+    public abstract M linear();
+
+    /** Return a matrix containing the transpose of the linear portion of this transform.
+     * The returned instance is linear, meaning it has a translation component of zero.
+     * @return a matrix containing the transpose of the linear portion of this transform
+     */
+    public abstract M linearTranspose();
+
+    /** Return a transform suitable for transforming normals. The returned matrix is
+     * the inverse transpose of the linear portion of this instance, i.e.
+     * <code>N = (L<sup>-1</sup>)<sup>T</sup></code>, where <code>L</code> is the linear portion
+     * of this instance and <code>N</code> is the returned matrix. Note that normals
+     * transformed with the returned matrix may be scaled during transformation and require
+     * normalization.
+     * @return a transform suitable for transforming normals
+     * @throws IllegalStateException if the matrix cannot be inverted
+     * @see <a href="https://en.wikipedia.org/wiki/Normal_(geometry)#Transforming_normals">Transforming normals</a>
+     */
+    public M normalTransform() {
+        return inverse().linearTranspose();
+    }
+
+    /** {@inheritDoc}
      *
      * <p>This method returns true if the determinant of the matrix is positive.</p>
      */
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java
index 66cfd36..0b773df 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java
@@ -31,7 +31,7 @@ import org.apache.commons.geometry.euclidean.internal.Vectors;
 * use arrays containing 2 elements, instead of 4.
 * </p>
 */
-public final class AffineTransformMatrix1D extends AbstractAffineTransformMatrix<Vector1D> {
+public final class AffineTransformMatrix1D extends AbstractAffineTransformMatrix<Vector1D, AffineTransformMatrix1D> {
     /** The number of internal matrix elements. */
     private static final int NUM_ELEMENTS = 2;
 
@@ -112,6 +112,34 @@ public final class AffineTransformMatrix1D extends AbstractAffineTransformMatrix
         return m00;
     }
 
+    /** {@inheritDoc}
+     *
+     * <p><strong>Example</strong>
+     * <pre>
+     *      [ a, b ]   [ a, 0 ]
+     *      [ 0, 1 ] &rarr; [ 0, 1 ]
+     * </pre>
+     */
+    @Override
+    public AffineTransformMatrix1D linear() {
+        return new AffineTransformMatrix1D(m00, 0.0);
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>In the one dimensional case, this is exactly the same as {@link #linear()}.</p>
+     *
+     * <p><strong>Example</strong>
+     * <pre>
+     *      [ a, b ]   [ a, 0 ]
+     *      [ 0, 1 ] &rarr; [ 0, 1 ]
+     * </pre>
+     */
+    @Override
+    public AffineTransformMatrix1D linearTranspose() {
+        return linear();
+    }
+
     /** Get a new transform containing the result of applying a translation logically after
      * the transformation represented by the current instance. This is achieved by
      * creating a new translation transform and pre-multiplying it with the current
@@ -200,7 +228,7 @@ public final class AffineTransformMatrix1D extends AbstractAffineTransformMatrix
 
     /** {@inheritDoc}
      *
-     * @throws IllegalStateException if the transform matrix cannot be inverted
+     * @throws IllegalStateException if the matrix cannot be inverted
      */
     @Override
     public AffineTransformMatrix1D inverse() {
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java
index a0fcf21..ab46582 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java
@@ -34,7 +34,7 @@ import org.apache.commons.numbers.core.Precision;
  * use arrays containing 12 elements, instead of 16.
  * </p>
  */
-public final class AffineTransformMatrix3D extends AbstractAffineTransformMatrix<Vector3D> {
+public final class AffineTransformMatrix3D extends AbstractAffineTransformMatrix<Vector3D, AffineTransformMatrix3D> {
     /** The number of internal matrix elements. */
     private static final int NUM_ELEMENTS = 12;
 
@@ -206,6 +206,42 @@ public final class AffineTransformMatrix3D extends AbstractAffineTransformMatrix
             );
     }
 
+    /** {@inheritDoc}
+     *
+     * <p><strong>Example</strong>
+     * <pre>
+     *      [ a, b, c, d ]   [ a, b, c, 0 ]
+     *      [ e, f, g, h ]   [ e, f, g, 0 ]
+     *      [ i, j, k, l ] &rarr; [ i, j, k, 0 ]
+     *      [ 0, 0, 0, 1 ]   [ 0, 0, 0, 1 ]
+     * </pre>
+     */
+    @Override
+    public AffineTransformMatrix3D linear() {
+        return new AffineTransformMatrix3D(
+                m00, m01, m02, 0.0,
+                m10, m11, m12, 0.0,
+                m20, m21, m22, 0.0);
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p><strong>Example</strong>
+     * <pre>
+     *      [ a, b, c, d ]   [ a, e, i, 0 ]
+     *      [ e, f, g, h ]   [ b, f, j, 0 ]
+     *      [ i, j, k, l ] &rarr; [ c, g, k, 0 ]
+     *      [ 0, 0, 0, 1 ]   [ 0, 0, 0, 1 ]
+     * </pre>
+     */
+    @Override
+    public AffineTransformMatrix3D linearTranspose() {
+        return new AffineTransformMatrix3D(
+                m00, m10, m20, 0.0,
+                m01, m11, m21, 0.0,
+                m02, m12, m22, 0.0);
+    }
+
     /** Apply a translation to the current instance, returning the result as a new transform.
      * @param translation vector containing the translation values for each axis
      * @return a new transform containing the result of applying a translation to
@@ -318,7 +354,7 @@ public final class AffineTransformMatrix3D extends AbstractAffineTransformMatrix
 
     /** {@inheritDoc}
     *
-    * @throws IllegalStateException if the transform matrix cannot be inverted
+    * @throws IllegalStateException if the matrix cannot be inverted
     */
     @Override
     public AffineTransformMatrix3D inverse() {
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java
index a311fa2..b45ac3e 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java
@@ -33,7 +33,7 @@ import org.apache.commons.numbers.core.Precision;
 * use arrays containing 6 elements, instead of 9.
 * </p>
 */
-public final class AffineTransformMatrix2D extends AbstractAffineTransformMatrix<Vector2D> {
+public final class AffineTransformMatrix2D extends AbstractAffineTransformMatrix<Vector2D, AffineTransformMatrix2D> {
     /** The number of internal matrix elements. */
     private static final int NUM_ELEMENTS = 6;
 
@@ -170,6 +170,38 @@ public final class AffineTransformMatrix2D extends AbstractAffineTransformMatrix
             );
     }
 
+    /** {@inheritDoc}
+     *
+     * <p><strong>Example</strong>
+     * <pre>
+     *      [ a, b, c ]   [ a, b, 0 ]
+     *      [ d, e, f ] &rarr; [ d, e, 0 ]
+     *      [ 0, 0, 1 ]   [ 0, 0, 1 ]
+     * </pre>
+     */
+    @Override
+    public AffineTransformMatrix2D linear() {
+        return new AffineTransformMatrix2D(
+                m00, m01, 0.0,
+                m10, m11, 0.0);
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p><strong>Example</strong>
+     * <pre>
+     *      [ a, b, c ]   [ a, d, 0 ]
+     *      [ d, e, f ] &rarr; [ b, e, 0 ]
+     *      [ 0, 0, 1 ]   [ 0, 0, 1 ]
+     * </pre>
+     */
+    @Override
+    public AffineTransformMatrix2D linearTranspose() {
+        return new AffineTransformMatrix2D(
+                m00, m10, 0.0,
+                m01, m11, 0.0);
+    }
+
     /** Apply a translation to the current instance, returning the result as a new transform.
      * @param translation vector containing the translation values for each axis
      * @return a new transform containing the result of applying a translation to
@@ -277,7 +309,7 @@ public final class AffineTransformMatrix2D extends AbstractAffineTransformMatrix
 
     /** {@inheritDoc}
     *
-    * @throws IllegalStateException if the transform matrix cannot be inverted
+    * @throws IllegalStateException if the matrix cannot be inverted
     */
     @Override
     public AffineTransformMatrix2D inverse() {
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java
index 28b6e98..ebd92c2 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java
@@ -533,6 +533,72 @@ public class AffineTransformMatrix1DTest {
     }
 
     @Test
+    public void testLinear() {
+        // arrange
+        AffineTransformMatrix1D mat = AffineTransformMatrix1D.of(2, 3);
+
+        // act
+        AffineTransformMatrix1D result = mat.linear();
+
+        // assert
+        Assert.assertArrayEquals(new double[] {2, 0}, result.toArray(), 0.0);
+    }
+
+    @Test
+    public void testLinearTranspose() {
+        // arrange
+        AffineTransformMatrix1D mat = AffineTransformMatrix1D.of(2, 3);
+
+        // act
+        AffineTransformMatrix1D result = mat.linearTranspose();
+
+        // assert
+        Assert.assertArrayEquals(new double[] {2, 0}, result.toArray(), 0.0);
+    }
+
+    @Test
+    public void testNormalTransform() {
+        // act/assert
+        checkNormalTransform(AffineTransformMatrix1D.identity());
+
+        checkNormalTransform(AffineTransformMatrix1D.createTranslation(4));
+        checkNormalTransform(AffineTransformMatrix1D.createTranslation(-4));
+
+        checkNormalTransform(AffineTransformMatrix1D.createScale(2));
+        checkNormalTransform(AffineTransformMatrix1D.createScale(-2));
+
+        checkNormalTransform(AffineTransformMatrix1D.createScale(2).translate(3));
+        checkNormalTransform(AffineTransformMatrix1D.createScale(2).translate(-3));
+        checkNormalTransform(AffineTransformMatrix1D.createTranslation(2).scale(-3));
+        checkNormalTransform(AffineTransformMatrix1D.createTranslation(-4).scale(-1));
+    }
+
+    private void checkNormalTransform(AffineTransformMatrix1D transform) {
+        AffineTransformMatrix1D normalTransform = transform.normalTransform();
+
+        Vector1D expectedPlus = transform.apply(Vector1D.Unit.PLUS)
+                .subtract(transform.apply(Vector1D.ZERO))
+                .normalize();
+
+        Vector1D expectedMinus = transform.apply(Vector1D.Unit.MINUS)
+                .subtract(transform.apply(Vector1D.ZERO))
+                .normalize();
+
+        EuclideanTestUtils.assertCoordinatesEqual(expectedPlus,
+                normalTransform.apply(Vector1D.Unit.PLUS).normalize(), EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(expectedMinus,
+                normalTransform.apply(Vector1D.Unit.MINUS).normalize(), EPS);
+    }
+
+    @Test
+    public void testNormalTransform_nonInvertible() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AffineTransformMatrix1D.createScale(0).normalTransform();
+        }, IllegalStateException.class);
+    }
+
+    @Test
     public void testInverse_identity() {
         // act
         AffineTransformMatrix1D inverse = AffineTransformMatrix1D.identity().inverse();
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java
index 05969bb..31da300 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java
@@ -19,6 +19,8 @@ package org.apache.commons.geometry.euclidean.threed;
 import java.util.function.UnaryOperator;
 
 import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils.PermuteCallback3D;
 import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
@@ -31,6 +33,9 @@ public class AffineTransformMatrix3DTest {
 
     private static final double EPS = 1e-12;
 
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(EPS);
+
     @Test
     public void testOf() {
         // arrange
@@ -1020,6 +1025,108 @@ public class AffineTransformMatrix3DTest {
     }
 
     @Test
+    public void testLinear() {
+        // arrange
+        AffineTransformMatrix3D mat = AffineTransformMatrix3D.of(
+                2, 3, 4, 5,
+                6, 7, 8, 9,
+                10, 11, 12, 13);
+
+        // act
+        AffineTransformMatrix3D result = mat.linear();
+
+        // assert
+        double[] expected = {
+            2, 3, 4, 0,
+            6, 7, 8, 0,
+            10, 11, 12, 0
+        };
+        Assert.assertArrayEquals(expected, result.toArray(), 0.0);
+    }
+
+    @Test
+    public void testLinearTranspose() {
+        // arrange
+        AffineTransformMatrix3D mat = AffineTransformMatrix3D.of(
+                2, 3, 4, 5,
+                6, 7, 8, 9,
+                10, 11, 12, 13);
+
+        // act
+        AffineTransformMatrix3D result = mat.linearTranspose();
+
+        // assert
+        double[] expected = {
+            2, 6, 10, 0,
+            3, 7, 11, 0,
+            4, 8, 12, 0
+        };
+        Assert.assertArrayEquals(expected, result.toArray(), 0.0);
+    }
+
+    @Test
+    public void testNormalTransform() {
+        // act/assert
+        checkNormalTransform(AffineTransformMatrix3D.identity());
+
+        checkNormalTransform(AffineTransformMatrix3D.createTranslation(2, 3, 4));
+        checkNormalTransform(AffineTransformMatrix3D.createTranslation(-3, -4, -5));
+
+        checkNormalTransform(AffineTransformMatrix3D.createScale(2, 5, 0.5));
+        checkNormalTransform(AffineTransformMatrix3D.createScale(-3, 4, 2));
+        checkNormalTransform(AffineTransformMatrix3D.createScale(-0.1, -0.5, 0.8));
+        checkNormalTransform(AffineTransformMatrix3D.createScale(-2, -5, -8));
+
+        QuaternionRotation rotA = QuaternionRotation.fromAxisAngle(Vector3D.of(2, 3, 4), 0.75 * Math.PI);
+        QuaternionRotation rotB = QuaternionRotation.fromAxisAngle(Vector3D.of(-1, 1, -1), 1.75 * Math.PI);
+
+        checkNormalTransform(AffineTransformMatrix3D.createRotation(Vector3D.of(1, 1, 1), rotA));
+        checkNormalTransform(AffineTransformMatrix3D.createRotation(Vector3D.of(-1, -1, -1), rotB));
+
+        checkNormalTransform(AffineTransformMatrix3D.createTranslation(2, 3, 4)
+                .scale(7, 5, 4)
+                .rotate(rotA));
+        checkNormalTransform(AffineTransformMatrix3D.createRotation(Vector3D.ZERO, rotB)
+                .translate(7, 5, 4)
+                .rotate(rotA)
+                .scale(2, 3, 0.5));
+    }
+
+    private void checkNormalTransform(AffineTransformMatrix3D transform) {
+        AffineTransformMatrix3D normalTransform = transform.normalTransform();
+
+        Vector3D p1 = Vector3D.of(-0.25, 0.75, 0.5);
+        Vector3D p2 = Vector3D.of(0.5, -0.75, 0.25);
+
+        Vector3D t1 = transform.apply(p1);
+        Vector3D t2 = transform.apply(p2);
+
+        EuclideanTestUtils.permute(-10, 10, 1, (x, y, z) -> {
+            Vector3D p3 = Vector3D.of(x, y, z);
+            Vector3D n = Plane.fromPoints(p1, p2, p3, TEST_PRECISION).getNormal();
+
+            Vector3D t3 = transform.apply(p3);
+
+            Plane tPlane = transform.preservesOrientation() ?
+                    Plane.fromPoints(t1, t2, t3, TEST_PRECISION) :
+                    Plane.fromPoints(t1, t3, t2, TEST_PRECISION);
+            Vector3D expected = tPlane.getNormal();
+
+            Vector3D actual = normalTransform.apply(n).normalize();
+
+            EuclideanTestUtils.assertCoordinatesEqual(expected, actual, EPS);
+        });
+    }
+
+    @Test
+    public void testNormalTransform_nonInvertible() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AffineTransformMatrix3D.createScale(0).normalTransform();
+        }, IllegalStateException.class);
+    }
+
+    @Test
     public void testHashCode() {
         // arrange
         double[] values = new double[] {
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java
index 1ebf2be..0ac3e7a 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java
@@ -19,6 +19,8 @@ package org.apache.commons.geometry.euclidean.twod;
 import java.util.function.UnaryOperator;
 
 import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
 import org.apache.commons.numbers.angle.PlaneAngleRadians;
 import org.junit.Assert;
@@ -28,6 +30,9 @@ public class AffineTransformMatrix2DTest {
 
     private static final double EPS = 1e-12;
 
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(EPS);
+
     @Test
     public void testOf() {
         // arrange
@@ -981,6 +986,96 @@ public class AffineTransformMatrix2DTest {
     }
 
     @Test
+    public void testLinear() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D.of(
+                2, 3, 4,
+                5, 6, 7);
+
+        // act
+        AffineTransformMatrix2D result = mat.linear();
+
+        // assert
+        double[] expected = {
+            2, 3, 0,
+            5, 6, 0
+        };
+        Assert.assertArrayEquals(expected, result.toArray(), 0.0);
+    }
+
+    @Test
+    public void testLinearTranspose() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D.of(
+                2, 3, 4,
+                5, 6, 7);
+
+        // act
+        AffineTransformMatrix2D result = mat.linearTranspose();
+
+        // assert
+        double[] expected = {
+            2, 5, 0,
+            3, 6, 0
+        };
+        Assert.assertArrayEquals(expected, result.toArray(), 0.0);
+    }
+
+    @Test
+    public void testNormalTransform() {
+        // act/assert
+        checkNormalTransform(AffineTransformMatrix2D.identity());
+
+        checkNormalTransform(AffineTransformMatrix2D.createTranslation(2, 3));
+        checkNormalTransform(AffineTransformMatrix2D.createTranslation(-3, -4));
+
+        checkNormalTransform(AffineTransformMatrix2D.createScale(2, 5));
+        checkNormalTransform(AffineTransformMatrix2D.createScale(-3, 4));
+        checkNormalTransform(AffineTransformMatrix2D.createScale(-2, -5));
+
+        checkNormalTransform(AffineTransformMatrix2D.createRotation(PlaneAngleRadians.PI_OVER_TWO));
+        checkNormalTransform(AffineTransformMatrix2D.createRotation(PlaneAngleRadians.THREE_PI_OVER_TWO));
+
+        checkNormalTransform(AffineTransformMatrix2D.createRotation(Vector2D.of(3, 4), PlaneAngleRadians.THREE_PI_OVER_TWO)
+                .translate(8, 2)
+                .scale(-3, -2));
+        checkNormalTransform(AffineTransformMatrix2D.createScale(2, -1)
+                .translate(-3, -4)
+                .rotate(Vector2D.of(-0.5, 0.5), 0.75 * Math.PI));
+    }
+
+    private void checkNormalTransform(AffineTransformMatrix2D transform) {
+        AffineTransformMatrix2D normalTransform = transform.normalTransform();
+
+        Vector2D p1 = Vector2D.of(-0.25, 0.75);
+        Vector2D t1 = transform.apply(p1);
+
+        EuclideanTestUtils.permute(-10, 10, 1, (x, y) -> {
+            Vector2D p2 = Vector2D.of(x, y);
+            Vector2D n = Line.fromPoints(p1, p2, TEST_PRECISION).getOffsetDirection();
+
+            Vector2D t2 = transform.apply(p2);
+
+            Line tLine = transform.preservesOrientation() ?
+                    Line.fromPoints(t1, t2, TEST_PRECISION) :
+                    Line.fromPoints(t2, t1, TEST_PRECISION);
+            Vector2D expected = tLine.getOffsetDirection();
+
+            Vector2D actual = normalTransform.apply(n).normalize();
+
+            EuclideanTestUtils.assertCoordinatesEqual(expected, actual, EPS);
+        });
+    }
+
+    @Test
+    public void testNormalTransform_nonInvertible() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AffineTransformMatrix2D.createScale(0).normalTransform();
+        }, IllegalStateException.class);
+    }
+
+    @Test
     public void testHashCode() {
         // arrange
         double[] values = new double[] {