using Microsoft.VisualStudio.TestTools.UnitTesting; using NavisworksTransport.Utils.CoordinateSystem; using System; using System.Numerics; namespace NavisworksTransport.UnitTests.CoordinateSystem { [TestClass] public class AlignToGroundMinCrossSectionYawSearcherTests { // ----- 验证辅助 ----- private static double Deg(double deg) => deg * Math.PI / 180.0; private static double RadToDeg(double rad) => rad * 180.0 / Math.PI; /// /// 计算在 q 姿态下物体沿 hostSide/hostUp 的截面投影面积。 /// 与 Searcher 内部 EvaluateCrossSectionArea 公式一致,用于独立验证搜索结果。 /// private static double ComputeCrossSectionArea( Quaternion q, double sizeX, double sizeY, double sizeZ, Vector3 hostSide, Vector3 hostUp) { Vector3 localX = Vector3.Normalize(Vector3.Transform(Vector3.UnitX, q)); Vector3 localY = Vector3.Normalize(Vector3.Transform(Vector3.UnitY, q)); Vector3 localZ = Vector3.Normalize(Vector3.Transform(Vector3.UnitZ, q)); double width = Math.Abs(Vector3.Dot(localX, hostSide)) * sizeX + Math.Abs(Vector3.Dot(localY, hostSide)) * sizeY + Math.Abs(Vector3.Dot(localZ, hostSide)) * sizeZ; double height = Math.Abs(Vector3.Dot(localX, hostUp)) * sizeX + Math.Abs(Vector3.Dot(localY, hostUp)) * sizeY + Math.Abs(Vector3.Dot(localZ, hostUp)) * sizeZ; return width * height; } /// /// 从结果四元数反算绕 hostUp 的 deltaYaw(度)。 /// deltaQ = inverse(faceDown) * result;其旋转轴应近似 hostUp。 /// private static double ExtractDeltaYawDegrees(Quaternion faceDown, Quaternion result, Vector3 hostUp) { Quaternion deltaQ = Quaternion.Normalize(Quaternion.Inverse(faceDown) * result); // 提取旋转角度(取绝对值,deltaQ 可能是 q 或 -q,表示同一旋转) double wClamped = Math.Min(1.0, Math.Abs(deltaQ.W)); double angleRad = 2.0 * Math.Acos(wClamped); // 轴分量与 hostUp 同向为正,反向为负 Vector3 axis = new Vector3(deltaQ.X, deltaQ.Y, deltaQ.Z); double sign = Vector3.Dot(axis, hostUp) >= 0 ? 1.0 : -1.0; return sign * RadToDeg(angleRad); } // ----- 回归保护:平地 + 路径沿 local X,长方体长边已对齐路径 ----- [TestMethod] public void FlatFace_PathAlongX_LongSideAlreadyAligned_KeepsZeroYaw_ZUp() { // sizeX=8(长) sizeY=4 sizeZ=2,路径沿 +X,ZUp // 贴地=Identity(面已朝下),CAD=Identity // yaw=0 时:width(沿Y)=4, height(沿Z)=2, 面积=8(最小) // yaw=90° 时:width=8, height=2, 面积=16(更大) // 期望搜索结果 ≈ 0° var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); Quaternion faceDown = Quaternion.Identity; Quaternion cadRot = Quaternion.Identity; Quaternion q = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadRot, sizeX: 8.0, sizeY: 4.0, sizeZ: 2.0, hostPathForward: Vector3.UnitX, adapter: adapter, out _, out _, out _); double deltaYaw = ExtractDeltaYawDegrees(faceDown, q, adapter.HostUpVector3); Assert.AreEqual(0.0, deltaYaw, 1.5, $"长边已对齐路径时 yaw 应保持 0°,实际={deltaYaw:F2}°"); } // ----- 关键:平地 + 路径沿 Y,长方体需转 90° 才让长边对齐路径 ----- [TestMethod] public void FlatFace_PathAlongY_SearchFinds90DegToAlignLongSide_ZUp() { // sizeX=8(长) sizeY=4 sizeZ=2,路径沿 +Y,ZUp // yaw=0:local X=+X,width(沿hostSide)=|cross(+Y,+Z)·X|*8 + ... = |+X·+X|*8 + 0 + 0 = 8(宽方向是X),height(沿Z)=2,面积=16 // 注:pathFwd=+Y, hostUp=+Z, hostSide=cross(+Y,+Z)=+X // yaw=0: localX=+X,localY=+Y,localZ=+Z;width=|X·X|*8+|Y·X|*4+|Z·X|*2=8;height=|X·Z|*8+|Y·Z|*4+|Z·Z|*2=2;面积=16 // yaw=90°(绕Z):localX=+Y,localY=-X,localZ=+Z;width=|Y·X|*8+|-X·X|*4+|Z·X|*2=4;height=|Y·Z|*8+|-X·Z|*4+|Z·Z|*2=2;面积=8(最小) // 期望搜索结果 ≈ ±90°(两个解面积相同,取其一) var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); Quaternion faceDown = Quaternion.Identity; Quaternion cadRot = Quaternion.Identity; Quaternion q = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadRot, sizeX: 8.0, sizeY: 4.0, sizeZ: 2.0, hostPathForward: Vector3.UnitY, adapter: adapter, out _, out _, out _); double deltaYaw = ExtractDeltaYawDegrees(faceDown, q, adapter.HostUpVector3); // 接受 +90 或 -90(面积相同),用 mod 180 容差判断 double mod180 = Math.Abs(deltaYaw) % 180.0; Assert.AreEqual(90.0, mod180, 2.0, $"路径沿 Y、长边沿 X 时应搜索到 ±90° yaw 使长边对齐路径,实际={deltaYaw:F2}°"); // 验证结果面积确实最小(=8),优于 yaw=0 的 16 Vector3 hostSide = Vector3.Normalize(Vector3.Cross(Vector3.UnitY, Vector3.UnitZ)); double resultArea = ComputeCrossSectionArea(q, 8.0, 4.0, 2.0, hostSide, Vector3.UnitZ); double zeroYawArea = ComputeCrossSectionArea(faceDown, 8.0, 4.0, 2.0, hostSide, Vector3.UnitZ); Assert.IsTrue(resultArea < zeroYawArea - 1e-3, $"搜索结果面积 {resultArea:F3} 应小于 yaw=0 面积 {zeroYawArea:F3}"); Assert.AreEqual(8.0, resultArea, 0.1, $"最小截面面积应 ≈ 8,实际={resultArea:F3}"); } // ----- YUp 宿主下同样行为 ----- [TestMethod] public void FlatFace_PathAlongY_SearchFinds90Deg_YUp() { // YUp:hostUp=+Y。sizeX=8(长) sizeY=4 sizeZ=2,路径沿 +Y(=hostUp)? // 不对——路径沿 hostUp 是纯垂直,会退化。改路径沿 +Z(YUp 下水平方向之一) // YUp 下水平面是 XZ,pathFwd=+Z, hostUp=+Y, hostSide=cross(+Z,+Y)=-X // yaw=0: localX=+X,localY=+Y,localZ=+Z // width(沿-X)=|X·-X|*8+|Y·-X|*4+|Z·-X|*2=8;height(沿+Y)=|X·Y|*8+|Y·Y|*4+|Z·Y|*2=4;面积=32 // yaw=90°(绕+Y): localX=+Z,localY=+Y,localZ=-X // width(沿-X)=|Z·-X|*8+|Y·-X|*4+|-X·-X|*2=2;height(沿+Y)=0+|Y·Y|*4+0=4;面积=8(最小) var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); Quaternion faceDown = Quaternion.Identity; Quaternion cadRot = Quaternion.Identity; Quaternion q = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadRot, sizeX: 8.0, sizeY: 4.0, sizeZ: 2.0, hostPathForward: Vector3.UnitZ, adapter: adapter, out _, out _, out _); double deltaYaw = ExtractDeltaYawDegrees(faceDown, q, adapter.HostUpVector3); double mod180 = Math.Abs(deltaYaw) % 180.0; Assert.AreEqual(90.0, mod180, 2.0, $"YUp 下路径沿 Z、长边沿 X 时应搜索到 ±90° yaw,实际={deltaYaw:F2}°"); } // ----- 贴地旋转已固定俯仰,验证搜索仍能找到使截面最小的 yaw ----- [TestMethod] public void TiltedFaceDown_SearchStillFindsMinCrossSection_ZUp() { // 构造贴地旋转:绕 X 轴翻 30°(模拟贴地后俯仰已固定) // 物体 8×4×2,路径沿 +X,ZUp // 搜索应在绕 Z 的 yaw 上找到最小截面 var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); Quaternion faceDown = Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)Deg(30.0)); Quaternion cadRot = Quaternion.Identity; Quaternion q = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadRot, sizeX: 8.0, sizeY: 4.0, sizeZ: 2.0, hostPathForward: Vector3.UnitX, adapter: adapter, out _, out _, out _); // 验证:搜索结果的截面面积 <= yaw=0(只用 faceDown) 的面积 Vector3 hostSide = Vector3.Normalize(Vector3.Cross(Vector3.UnitX, Vector3.UnitZ)); double resultArea = ComputeCrossSectionArea(q, 8.0, 4.0, 2.0, hostSide, Vector3.UnitZ); double faceDownOnlyArea = ComputeCrossSectionArea(faceDown, 8.0, 4.0, 2.0, hostSide, Vector3.UnitZ); Assert.IsTrue(resultArea <= faceDownOnlyArea + 1e-3, $"搜索结果面积 {resultArea:F3} 应不大于仅贴地(yaw=0)面积 {faceDownOnlyArea:F3}"); } // ----- CAD 姿态天然歪斜:验证搜索在歪斜 CAD 基础上仍找到最小截面 ----- [TestMethod] public void SkewedCadRotation_SearchFindsMinCrossSection_ZUp() { // CAD 姿态自带 45° 绕 Z 旋转(天然歪斜),物体 8×4×2,路径沿 +X // 搜索应找到补偿 yaw 使截面最小 var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); Quaternion faceDown = Quaternion.Identity; Quaternion cadRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, (float)Deg(45.0)); Quaternion q = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadRot, sizeX: 8.0, sizeY: 4.0, sizeZ: 2.0, hostPathForward: Vector3.UnitX, adapter: adapter, out _, out _, out _); // 验证:搜索结果面积 <= 不搜索(只用 faceDown) 的面积 Vector3 hostSide = Vector3.Normalize(Vector3.Cross(Vector3.UnitX, Vector3.UnitZ)); double resultArea = ComputeCrossSectionArea(q, 8.0, 4.0, 2.0, hostSide, Vector3.UnitZ); double noYawArea = ComputeCrossSectionArea(faceDown * cadRot, 8.0, 4.0, 2.0, hostSide, Vector3.UnitZ); Assert.IsTrue(resultArea <= noYawArea + 1e-3, $"CAD 歪斜时搜索结果面积 {resultArea:F3} 应不大于不搜索面积 {noYawArea:F3}"); } // ----- 退化:纯垂直路径(pathFwd 平行 hostUp),保持贴地姿态 ----- [TestMethod] public void VerticalPath_ReturnsFaceDownUnchanged_ZUp() { var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); Quaternion faceDown = Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)Deg(20.0)); Quaternion cadRot = Quaternion.Identity; Quaternion q = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadRot, sizeX: 8.0, sizeY: 4.0, sizeZ: 2.0, hostPathForward: Vector3.UnitZ, adapter: adapter, // ZUp 下 +Z = hostUp out _, out _, out _); Assert.AreEqual(faceDown.X, q.X, 1e-6); Assert.AreEqual(faceDown.Y, q.Y, 1e-6); Assert.AreEqual(faceDown.Z, q.Z, 1e-6); Assert.AreEqual(faceDown.W, q.W, 1e-6); } // ----- 立方体(三轴等长):yaw 不影响面积,应返回有效结果不崩溃 ----- [TestMethod] public void Cube_AllAxesEqual_ReturnsValidResult_NoCrash() { var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); Quaternion faceDown = Quaternion.Identity; Quaternion cadRot = Quaternion.Identity; Quaternion q = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadRot, sizeX: 4.0, sizeY: 4.0, sizeZ: 4.0, hostPathForward: Vector3.UnitX, adapter: adapter, out _, out _, out _); // 立方体任何 yaw 面积相同,只要返回有效四元数即可 Assert.AreEqual(1.0, Quaternion.Normalize(q).Length(), 1e-5); } // ----- ComposeHostCorrection:扣除 CAD yaw + pathYaw,避免重复旋转 ----- /// /// PathAnimationManager 增量链语义闭环验证: /// finalQ = qup(pathYaw - currentYaw) * CreateHostRotationCorrection(correction) * cadQ /// 应等于 overrideQ * cadQ(贴地时实际施加的最终姿态) /// cadQ=Identity 时 currentYaw=0,简化为: /// qup(pathYaw) * correctionHostQ == overrideQ /// private static void AssertIncrementalChainClosure( Quaternion overrideQ, Quaternion cadQ, double pathYawRadians, HostCoordinateAdapter adapter, double tolerance = 1e-3) { LocalEulerRotationCorrection correction = AlignToGroundMinCrossSectionYawSearcher.ComposeHostCorrection( overrideQ, cadQ, pathYawRadians, adapter); Quaternion correctionHostQ = adapter.CreateHostRotationCorrection(correction); // currentYaw 从 cadQ 提取(与 ComposeHostCorrection 内部一致) Matrix4x4 cadLinear = Matrix4x4.CreateFromQuaternion(cadQ); Vector3 cadFwdHost = new Vector3(cadLinear.M11, cadLinear.M21, cadLinear.M31); Vector3 cadFwdCanon = adapter.ToCanonicalVector3(cadFwdHost); cadFwdCanon.Z = 0f; double currentYaw = cadFwdCanon.LengthSquared() > 1e-9f ? Math.Atan2(Vector3.Normalize(cadFwdCanon).Y, Vector3.Normalize(cadFwdCanon).X) : 0.0; // 增量链:finalQ = qup(pathYaw - currentYaw) * correctionHostQ * cadQ Quaternion deltaQ = Quaternion.CreateFromAxisAngle( adapter.HostUpVector3, (float)(pathYawRadians - currentYaw)); Quaternion reconstructedFinal = Quaternion.Normalize(deltaQ * correctionHostQ * cadQ); Quaternion expectedFinal = Quaternion.Normalize(overrideQ * cadQ); for (int axis = 0; axis < 3; axis++) { Vector3 v = axis == 0 ? Vector3.UnitX : (axis == 1 ? Vector3.UnitY : Vector3.UnitZ); Vector3 expected = Vector3.Normalize(Vector3.Transform(v, expectedFinal)); Vector3 actual = Vector3.Normalize(Vector3.Transform(v, reconstructedFinal)); double dot = Vector3.Dot(expected, actual); Assert.IsTrue(Math.Abs(dot - 1.0) < tolerance, $"axis {axis}: 增量链闭环偏差,dot={dot:F6}"); } } [TestMethod] public void ComposeCorrection_CadIdentity_PurePathYaw_ReturnsZeroCorrection_ZUp() { // CAD=Identity(currentYaw=0), overrideQ=纯 pathYaw, correction 应为 0 var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); double pathYaw = Deg(30.0); Quaternion cadQ = Quaternion.Identity; Quaternion overrideQ = Quaternion.CreateFromAxisAngle(adapter.HostUpVector3, (float)pathYaw); LocalEulerRotationCorrection correction = AlignToGroundMinCrossSectionYawSearcher.ComposeHostCorrection( overrideQ, cadQ, pathYaw, adapter); Assert.AreEqual(0.0, correction.XDegrees, 1e-3, $"X={correction.XDegrees:F4}"); Assert.AreEqual(0.0, correction.YDegrees, 1e-3, $"Y(up)={correction.YDegrees:F4}"); Assert.AreEqual(0.0, correction.ZDegrees, 1e-3, $"Z(nonUp)={correction.ZDegrees:F4}"); } [TestMethod] public void ComposeCorrection_CadIdentity_PurePathYaw_ReturnsZeroCorrection_YUp() { var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); double pathYaw = Deg(-15.62); Quaternion cadQ = Quaternion.Identity; Quaternion overrideQ = Quaternion.CreateFromAxisAngle(adapter.HostUpVector3, (float)pathYaw); LocalEulerRotationCorrection correction = AlignToGroundMinCrossSectionYawSearcher.ComposeHostCorrection( overrideQ, cadQ, pathYaw, adapter); Assert.AreEqual(0.0, correction.XDegrees, 1e-3, $"X={correction.XDegrees:F4}"); Assert.AreEqual(0.0, correction.YDegrees, 1e-3, $"Y(up)={correction.YDegrees:F4}"); Assert.AreEqual(0.0, correction.ZDegrees, 1e-3, $"Z(nonUp)={correction.ZDegrees:F4}"); } [TestMethod] public void ComposeCorrection_CadIdentity_FaceDownPlusPathYaw_Closure_YUp() { // CAD=Identity, overrideQ = pathYawQ * faceDown // 增量链闭环验证 var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); double pathYaw = Deg(-15.62); Quaternion cadQ = Quaternion.Identity; Quaternion faceDown = Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)Deg(15.0)); Quaternion pathYawQ = Quaternion.CreateFromAxisAngle(adapter.HostUpVector3, (float)pathYaw); Quaternion overrideQ = Quaternion.Normalize(pathYawQ * faceDown); AssertIncrementalChainClosure(overrideQ, cadQ, pathYaw, adapter); } [TestMethod] public void ComposeCorrection_CadIdentity_SearchedYawPlusFaceDown_Closure_YUp() { // 模拟真实贴地:CAD=Identity, Search 出 overrideQ, 验证增量链闭环 var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); Quaternion cadQ = Quaternion.Identity; Quaternion faceDown = Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)Deg(25.0)); Vector3 pathFwd = new Vector3(0.96f, 0.0f, -0.28f); Quaternion overrideQ = AlignToGroundMinCrossSectionYawSearcher.Search( faceDown, cadQ, sizeX: 8.0, sizeY: 4.0, sizeZ: 2.0, hostPathForward: pathFwd, adapter: adapter, out _, out _, out _); PathTargetFrameResolver.TryResolvePlanarHostYaw(pathFwd, CoordinateSystemType.YUp, out double pathYaw); AssertIncrementalChainClosure(overrideQ, cadQ, pathYaw, adapter, tolerance: 2e-3); } [TestMethod] public void ComposeCorrection_SkewedCad_IncrementalChainClosure_YUp() { // CAD 姿态天然歪斜(带 yaw),验证增量链仍闭环 // 这是日志中 25"x25" 的场景:cadQ 自带 -124° yaw var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); // 构造 CAD 姿态:绕 host Y(up) 转 -124° + 一些倾斜 Quaternion cadQ = Quaternion.Normalize( Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)Deg(-124.0)) * Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)Deg(10.0))); double pathYaw = Deg(-15.62); // overrideQ = searchedYawQ * faceDown(贴地搜索结果) Quaternion faceDown = Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)Deg(20.0)); Quaternion searchedYawQ = Quaternion.CreateFromAxisAngle(adapter.HostUpVector3, (float)Deg(-57.0)); Quaternion overrideQ = Quaternion.Normalize(searchedYawQ * faceDown); AssertIncrementalChainClosure(overrideQ, cadQ, pathYaw, adapter, tolerance: 2e-3); } [TestMethod] public void HostQuaternionToCanonical_HostUpRotation_MapsToCanonicalZRotation_YUp() { // YUp: host 绕 Y(up) 转 θ → canonical 绕 Z(up) 转 θ var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); double theta = Deg(37.0); Quaternion hostQ = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)theta); Quaternion canonQ = AlignToGroundMinCrossSectionYawSearcher.HostQuaternionToCanonical(hostQ, adapter); // canonical 下应是绕 Z 转 θ Quaternion expected = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, (float)theta); AssertQuaternionsEqual(expected, canonQ); } [TestMethod] public void HostQuaternionToCanonical_HostXRotation_MapsToCanonicalXRotation_YUp() { // YUp: host 绕 X(forward) 转 θ → canonical 绕 X(forward) 转 θ(X 轴两坐标系相同) var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); double theta = Deg(42.0); Quaternion hostQ = Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)theta); Quaternion canonQ = AlignToGroundMinCrossSectionYawSearcher.HostQuaternionToCanonical(hostQ, adapter); Quaternion expected = Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)theta); AssertQuaternionsEqual(expected, canonQ); } [TestMethod] public void HostQuaternionToCanonical_Identity_StaysIdentity_YUp() { // Identity 在两坐标系都应是 Identity(相似变换的不动点) var adapter = new HostCoordinateAdapter(CoordinateSystemType.YUp); Quaternion canonQ = AlignToGroundMinCrossSectionYawSearcher.HostQuaternionToCanonical( Quaternion.Identity, adapter); AssertQuaternionsEqual(Quaternion.Identity, canonQ); } [TestMethod] public void HostQuaternionToCanonical_ZUp_IsIdentity() { // ZUp: canonical=host,转换应返回原四元数 var adapter = new HostCoordinateAdapter(CoordinateSystemType.ZUp); Quaternion original = Quaternion.CreateFromAxisAngle( new Vector3(0.3f, 0.5f, 0.8f), (float)Deg(55.0f)); original = Quaternion.Normalize(original); Quaternion canonQ = AlignToGroundMinCrossSectionYawSearcher.HostQuaternionToCanonical(original, adapter); AssertQuaternionsEqual(original, canonQ); } private static void AssertQuaternionsEqual(Quaternion expected, Quaternion actual, double tolerance = 1e-4) { // 四元数有双覆盖性(q 和 -q 表示同一旋转),比较作用后的轴 for (int axis = 0; axis < 3; axis++) { Vector3 v = axis == 0 ? Vector3.UnitX : (axis == 1 ? Vector3.UnitY : Vector3.UnitZ); Vector3 e = Vector3.Normalize(Vector3.Transform(v, expected)); Vector3 a = Vector3.Normalize(Vector3.Transform(v, actual)); double dot = Vector3.Dot(e, a); Assert.IsTrue(Math.Abs(dot - 1.0) < tolerance, $"axis {axis}: expected≈actual 失败,dot={dot:F6}"); } } } }