/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using NUnit.Framework; using static Opc.Ua.Positioning.Tests.Transforms.TransformTestHelpers; namespace Opc.Ua.Positioning.Tests.Transforms { /// /// Tests for against the OPC 10000-210 Annex B /// frame conventions: right handed axes, roll A about X, pitch B about Y, yaw /// C about Z, intrinsic z-y'-x'' equivalent to Rz(C)*Ry(B)*Rx(A). /// [TestFixture] [Category("Positioning")] public sealed class RslFrameTransformTests { private const double kTolerance = 1e-12; [Test] public void YawNinetyDegreesRotatesXAxisToYAxis() { var t = RslFrameTransform.FromComponents( Orientation(0.0, 0.0, 90.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(1.0, 0.0, 0.0)); Assert.That(p.X, Is.Zero.Within(kTolerance)); Assert.That(p.Y, Is.EqualTo(1.0).Within(kTolerance)); Assert.That(p.Z, Is.Zero.Within(kTolerance)); } [Test] public void RollNinetyDegreesRotatesYAxisToZAxis() { var t = RslFrameTransform.FromComponents( Orientation(90.0, 0.0, 0.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(0.0, 1.0, 0.0)); Assert.That(p.X, Is.Zero.Within(kTolerance)); Assert.That(p.Y, Is.Zero.Within(kTolerance)); Assert.That(p.Z, Is.EqualTo(1.0).Within(kTolerance)); } [Test] public void PitchNinetyDegreesRotatesZAxisToXAxis() { var t = RslFrameTransform.FromComponents( Orientation(0.0, 90.0, 0.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(0.0, 0.0, 1.0)); Assert.That(p.X, Is.EqualTo(1.0).Within(kTolerance)); Assert.That(p.Y, Is.Zero.Within(kTolerance)); Assert.That(p.Z, Is.Zero.Within(kTolerance)); } [Test] public void CompositionDoesNotCommuteGoldenVectors() { var roll = RslFrameTransform.FromComponents( Orientation(90.0, 0.0, 0.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); var yaw = RslFrameTransform.FromComponents( Orientation(0.0, 0.0, 90.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); // Apply yaw first, then roll. ThreeDCartesianCoordinates yawThenRoll = roll.Compose(yaw).TransformPoint(Cartesian(1.0, 0.0, 0.0)); // Apply roll first, then yaw. ThreeDCartesianCoordinates rollThenYaw = yaw.Compose(roll).TransformPoint(Cartesian(1.0, 0.0, 0.0)); // Golden absolute results, not a mere round trip. Assert.That(yawThenRoll.X, Is.Zero.Within(kTolerance)); Assert.That(yawThenRoll.Y, Is.Zero.Within(kTolerance)); Assert.That(yawThenRoll.Z, Is.EqualTo(1.0).Within(kTolerance)); Assert.That(rollThenYaw.X, Is.Zero.Within(kTolerance)); Assert.That(rollThenYaw.Y, Is.EqualTo(1.0).Within(kTolerance)); Assert.That(rollThenYaw.Z, Is.Zero.Within(kTolerance)); Assert.That(Distance(yawThenRoll, rollThenYaw), Is.GreaterThan(1.0)); } [Test] public void IntrinsicOrderMatchesRzRyRxGoldenVector() { // A=90 (roll), C=90 (yaw), B=0 => R = Rz(90) * Rx(90). var t = RslFrameTransform.FromComponents( Orientation(90.0, 0.0, 90.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); ThreeDCartesianCoordinates onX = t.TransformPoint(Cartesian(1.0, 0.0, 0.0)); ThreeDCartesianCoordinates onY = t.TransformPoint(Cartesian(0.0, 1.0, 0.0)); ThreeDCartesianCoordinates onZ = t.TransformPoint(Cartesian(0.0, 0.0, 1.0)); // e_x -> e_y Assert.That(onX.X, Is.Zero.Within(kTolerance)); Assert.That(onX.Y, Is.EqualTo(1.0).Within(kTolerance)); Assert.That(onX.Z, Is.Zero.Within(kTolerance)); // e_y -> e_z (Rx sends e_y to e_z, Rz leaves e_z) Assert.That(onY.X, Is.Zero.Within(kTolerance)); Assert.That(onY.Y, Is.Zero.Within(kTolerance)); Assert.That(onY.Z, Is.EqualTo(1.0).Within(kTolerance)); // e_z -> Rx sends e_z to -e_y, Rz sends -e_y to e_x Assert.That(onZ.X, Is.EqualTo(1.0).Within(kTolerance)); Assert.That(onZ.Y, Is.Zero.Within(kTolerance)); Assert.That(onZ.Z, Is.Zero.Within(kTolerance)); } [Test] public void TransformPointAppliesRotationThenTranslation() { var t = RslFrameTransform.FromComponents( Orientation(0.0, 0.0, 90.0), Cartesian(5.0, -2.0, 3.0), AngleUnit.Degrees); ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(1.0, 0.0, 0.0)); Assert.That(p.X, Is.EqualTo(5.0).Within(kTolerance)); Assert.That(p.Y, Is.EqualTo(-1.0).Within(kTolerance)); Assert.That(p.Z, Is.EqualTo(3.0).Within(kTolerance)); } [Test] public void RoundTripRecoversAnglesAndTranslation() { ThreeDFrame frame = new() { CartesianCoordinates = Cartesian(1.5, -2.5, 3.5), Orientation = Orientation(10.0, 20.0, 30.0) }; var t = RslFrameTransform.FromFrame(frame, AngleUnit.Degrees); ThreeDFrame recovered = t.ToFrame(AngleUnit.Degrees); Assert.That(recovered.Orientation.A, Is.EqualTo(10.0).Within(1e-9)); Assert.That(recovered.Orientation.B, Is.EqualTo(20.0).Within(1e-9)); Assert.That(recovered.Orientation.C, Is.EqualTo(30.0).Within(1e-9)); Assert.That(recovered.CartesianCoordinates.X, Is.EqualTo(1.5).Within(kTolerance)); Assert.That(recovered.CartesianCoordinates.Y, Is.EqualTo(-2.5).Within(kTolerance)); Assert.That(recovered.CartesianCoordinates.Z, Is.EqualTo(3.5).Within(kTolerance)); } [Test] public void RadiansAndDegreesAreConsistent() { var degrees = RslFrameTransform.FromComponents( Orientation(30.0, 45.0, 60.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); var radians = RslFrameTransform.FromComponents( Orientation(System.Math.PI / 6.0, System.Math.PI / 4.0, System.Math.PI / 3.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Radians); ThreeDCartesianCoordinates a = degrees.TransformPoint(Cartesian(1.0, 2.0, 3.0)); ThreeDCartesianCoordinates b = radians.TransformPoint(Cartesian(1.0, 2.0, 3.0)); Assert.That(Distance(a, b), Is.LessThan(1e-12)); } [Test] public void InverseUndoesTransform() { var t = RslFrameTransform.FromComponents( Orientation(15.0, -35.0, 80.0), Cartesian(7.0, 8.0, -9.0), AngleUnit.Degrees); ThreeDCartesianCoordinates original = Cartesian(3.0, -4.0, 5.0); ThreeDCartesianCoordinates mapped = t.TransformPoint(original); ThreeDCartesianCoordinates back = t.Inverse().TransformPoint(mapped); Assert.That(Distance(back, original), Is.LessThan(1e-10)); } [Test] public void InverseComposedWithTransformIsIdentity() { var t = RslFrameTransform.FromComponents( Orientation(22.0, 33.0, 44.0), Cartesian(1.0, 2.0, 3.0), AngleUnit.Degrees); RslFrameTransform identity = t.Inverse().Compose(t); ThreeDCartesianCoordinates p = identity.TransformPoint(Cartesian(9.0, -8.0, 7.0)); Assert.That(p.X, Is.EqualTo(9.0).Within(1e-10)); Assert.That(p.Y, Is.EqualTo(-8.0).Within(1e-10)); Assert.That(p.Z, Is.EqualTo(7.0).Within(1e-10)); } [Test] public void GimbalLockResolvesRollToZeroDeterministically() { var original = RslFrameTransform.FromComponents( Orientation(30.0, 90.0, 40.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); ThreeDFrame decomposed = original.ToFrame(AngleUnit.Degrees); // At the pitch singularity the roll is deterministically folded to // zero and the pitch is exactly +90 degrees. Assert.That(decomposed.Orientation.A, Is.Zero.Within(1e-9)); Assert.That(decomposed.Orientation.B, Is.EqualTo(90.0).Within(1e-9)); // Even though the recovered angles differ, they rebuild the same // rotation (same action on every basis vector). var rebuilt = RslFrameTransform.FromFrame(decomposed, AngleUnit.Degrees); foreach (ThreeDCartesianCoordinates basis in new[] { Cartesian(1.0, 0.0, 0.0), Cartesian(0.0, 1.0, 0.0), Cartesian(0.0, 0.0, 1.0) }) { Assert.That( Distance(original.TransformPoint(basis), rebuilt.TransformPoint(basis)), Is.LessThan(1e-10)); } } [Test] public void NegativePitchGimbalLockIsHandled() { var original = RslFrameTransform.FromComponents( Orientation(25.0, -90.0, 65.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); ThreeDFrame decomposed = original.ToFrame(AngleUnit.Degrees); Assert.That(decomposed.Orientation.A, Is.Zero.Within(1e-9)); Assert.That(decomposed.Orientation.B, Is.EqualTo(-90.0).Within(1e-9)); var rebuilt = RslFrameTransform.FromFrame(decomposed, AngleUnit.Degrees); Assert.That( Distance( original.TransformPoint(Cartesian(1.0, 2.0, 3.0)), rebuilt.TransformPoint(Cartesian(1.0, 2.0, 3.0))), Is.LessThan(1e-10)); } [Test] public void IdentityTransformLeavesPointUnchanged() { ThreeDCartesianCoordinates p = RslFrameTransform.Identity.TransformPoint(Cartesian(4.0, 5.0, 6.0)); Assert.That(p.X, Is.EqualTo(4.0).Within(kTolerance)); Assert.That(p.Y, Is.EqualTo(5.0).Within(kTolerance)); Assert.That(p.Z, Is.EqualTo(6.0).Within(kTolerance)); } [Test] public void RotationMatrixIsProperOrthonormal() { var t = RslFrameTransform.FromComponents( Orientation(12.0, -34.0, 56.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); ArrayOf r = t.RotationMatrix; Assert.That(r.Count, Is.EqualTo(9)); // Column norms are one (orthonormal rotation). double col0 = (r[0] * r[0]) + (r[3] * r[3]) + (r[6] * r[6]); double col1 = (r[1] * r[1]) + (r[4] * r[4]) + (r[7] * r[7]); double col2 = (r[2] * r[2]) + (r[5] * r[5]) + (r[8] * r[8]); Assert.That(col0, Is.EqualTo(1.0).Within(1e-12)); Assert.That(col1, Is.EqualTo(1.0).Within(1e-12)); Assert.That(col2, Is.EqualTo(1.0).Within(1e-12)); } } }