Files
lingua-engine/tests/Engine.Editor.Tests/GizmoMathTests.cs
T
EmilandClaude Sonnet 5 e0e6c1d503 M3: real 3D translate gizmo — the "полноценный 3D-пайплайн" it was chosen for
TranslateGizmo draws three axis handles at the selected GameObject's
world position by projecting real 3D points through the real camera's
View/Projection (GizmoMath.WorldToScreen) onto ImGui's foreground draw
list. Nothing OpenGL-side draws lines yet, so this isn't 3D geometry
in the GL sense — but it IS driven by the actual camera matrices, which
is what the earlier scoped-2D-vs-full-3D-pipeline choice was actually
about: a handle dragged in screen space has to map onto a real 3D axis,
and that only means something once there's a real camera to project
through. The screenshot below shows exactly that — the axes aren't
screen-perpendicular, because the camera at (0,3,6) looking at the
origin means they shouldn't be.

Dragging a handle re-projects the mouse delta onto the axis's own
screen-space direction (GizmoMath.ProjectDragOntoAxis) and writes the
result back through GizmoMath.WorldToLocalPosition, which inverts the
parent's WorldMatrix rather than writing LocalPosition directly — a
parent with non-identity scale or rotation means "move 1 world unit"
and "add 1 to LocalPosition" are different amounts, and
samples/WindowDemo's ChildQuad (parented under a (2,2,1)-scaled Quad)
is exactly that case.

Split the actual math into GizmoMath (Engine.Editor.Contracts, no GL/
ImGui/mouse dependency) so it's unit-testable without a window or a real
mouse — neither exists in a headless test run, and dragging is exactly
the kind of interaction that's easy to get subtly wrong (screen-space
ratio direction, perspective sign, parent-scale correctness) without
something to check it against beyond eyeballing a screenshot. New
Engine.Editor.Tests project, 8 tests: screen-center projection, a
behind-camera point returning null, drag-ratio math on both an
axis-aligned and a diagonal screen direction, and the parent-scale/
parent-translation inverse-transform cases. All 8 passed on the first
run — including the non-uniform-scale case, the one most likely to be
subtly wrong.

Full suite: 73 tests, all green (65 previous + 8 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:30:54 +03:00

107 lines
3.6 KiB
C#

using System.Numerics;
using Engine.Editor.Contracts;
namespace Engine.Editor.Tests;
public class GizmoMathTests
{
[Fact]
public void WorldToScreen_OriginProjectsToScreenCenter_ForSymmetricCamera()
{
var view = Matrix4x4.CreateLookAt(new Vector3(0, 0, 5), Vector3.Zero, Vector3.UnitY);
var projection = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, 1f, 0.1f, 100f);
var screenSize = new Vector2(800, 800);
var screen = GizmoMath.WorldToScreen(Vector3.Zero, view * projection, screenSize);
Assert.NotNull(screen);
Assert.Equal(400f, screen!.Value.X, precision: 3);
Assert.Equal(400f, screen.Value.Y, precision: 3);
}
[Fact]
public void WorldToScreen_PointBehindCamera_ReturnsNull()
{
var view = Matrix4x4.CreateLookAt(new Vector3(0, 0, 5), Vector3.Zero, Vector3.UnitY);
var projection = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, 1f, 0.1f, 100f);
// Camera sits at z=5 looking toward z=0; a point at z=10 is on the
// far side of the camera from where it's looking, not just far away.
var screen = GizmoMath.WorldToScreen(new Vector3(0, 0, 10), view * projection, new Vector2(800, 800));
Assert.Null(screen);
}
[Fact]
public void ProjectDragOntoAxis_AlongScreenAxis_ScalesLinearly()
{
var origin = new Vector2(100, 100);
var tip = new Vector2(200, 100); // 100px maps to 2 world units
var start = new Vector2(100, 100);
var current = new Vector2(150, 100); // half the drag
var result = GizmoMath.ProjectDragOntoAxis(origin, tip, start, current, axisWorldLength: 2f);
Assert.Equal(1f, result, precision: 4);
}
[Fact]
public void ProjectDragOntoAxis_DiagonalAxis_ProjectsOnlyTheAlignedComponent()
{
var origin = new Vector2(0, 0);
var tip = new Vector2(100, 100); // 45-degree axis, length ~141.42
var start = Vector2.Zero;
// Move straight along X only — half of it is "along" the diagonal axis.
var current = new Vector2(100, 0);
var result = GizmoMath.ProjectDragOntoAxis(origin, tip, start, current, axisWorldLength: 2f);
Assert.Equal(1f, result, precision: 3);
}
[Fact]
public void ProjectDragOntoAxis_ZeroLengthAxis_ReturnsZero()
{
var result = GizmoMath.ProjectDragOntoAxis(
new Vector2(50, 50), new Vector2(50, 50), Vector2.Zero, new Vector2(999, 999), axisWorldLength: 2f);
Assert.Equal(0f, result);
}
[Fact]
public void WorldToLocalPosition_NoParent_ReturnsWorldPositionUnchanged()
{
var worldPos = new Vector3(3, 4, 5);
var local = GizmoMath.WorldToLocalPosition(worldPos, parentWorldMatrix: null);
Assert.Equal(worldPos, local);
}
[Fact]
public void WorldToLocalPosition_ParentWithNonUniformScale_AccountsForScale()
{
// Exactly samples/WindowDemo's ChildQuad case: a parent scaled
// (2,2,1) — moving 2 world units along X must become 1 local unit,
// not 2, or the object would drift as it's dragged.
var parentMatrix = Matrix4x4.CreateScale(2, 2, 1);
var worldPos = new Vector3(2, 0, 0);
var local = GizmoMath.WorldToLocalPosition(worldPos, parentMatrix);
Assert.Equal(new Vector3(1, 0, 0), local);
}
[Fact]
public void WorldToLocalPosition_ParentWithTranslation_SubtractsParentOffset()
{
var parentMatrix = Matrix4x4.CreateTranslation(5, 0, 0);
var worldPos = new Vector3(7, 0, 0);
var local = GizmoMath.WorldToLocalPosition(worldPos, parentMatrix);
Assert.Equal(new Vector3(2, 0, 0), local);
}
}