AI: Add 2D translation gizmo for axis-constrained node moves
This commit is contained in:
@@ -19,6 +19,8 @@ public partial class MapEditorPage : ContentPage
|
|||||||
private readonly Dictionary<MapNode, (double X, double Y)> _dragStartWorldPositions = new();
|
private readonly Dictionary<MapNode, (double X, double Y)> _dragStartWorldPositions = new();
|
||||||
private readonly HashSet<MapNode> _preMarqueeSelected = new();
|
private readonly HashSet<MapNode> _preMarqueeSelected = new();
|
||||||
private bool _draggingNode;
|
private bool _draggingNode;
|
||||||
|
private GizmoPart _gizmoPart = GizmoPart.None;
|
||||||
|
private bool _draggingGizmo;
|
||||||
private bool _marqueeSelecting;
|
private bool _marqueeSelecting;
|
||||||
private bool _panning;
|
private bool _panning;
|
||||||
private bool _panModeEnabled;
|
private bool _panModeEnabled;
|
||||||
@@ -266,6 +268,37 @@ public partial class MapEditorPage : ContentPage
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The translation gizmo wins over node hit-testing: grabbing an axis
|
||||||
|
// handle moves the active node's selection along that axis.
|
||||||
|
_gizmoPart = GizmoPart.None;
|
||||||
|
_draggingGizmo = false;
|
||||||
|
_drawable.ActiveGizmoPart = GizmoPart.None;
|
||||||
|
if (_selected is not null)
|
||||||
|
{
|
||||||
|
_gizmoPart = HitTestGizmo(position.Value, _selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_gizmoPart != GizmoPart.None)
|
||||||
|
{
|
||||||
|
_drawable.ActiveGizmoPart = _gizmoPart;
|
||||||
|
_pressNode = null;
|
||||||
|
_dragStartWorldPositions.Clear();
|
||||||
|
var (clickWorldX, clickWorldY) = ToWorld(position.Value);
|
||||||
|
_dragStartWorldPoint = new Point(clickWorldX, clickWorldY);
|
||||||
|
foreach (var node in _selectedNodes)
|
||||||
|
{
|
||||||
|
_dragStartWorldPositions[node] = (node.X, node.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selected is not null && !_dragStartWorldPositions.ContainsKey(_selected))
|
||||||
|
{
|
||||||
|
_dragStartWorldPositions[_selected] = (_selected.X, _selected.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
MapView.Invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_pressNode = HitTest(position.Value);
|
_pressNode = HitTest(position.Value);
|
||||||
|
|
||||||
if (_pressNode is not null)
|
if (_pressNode is not null)
|
||||||
@@ -276,7 +309,7 @@ public partial class MapEditorPage : ContentPage
|
|||||||
{
|
{
|
||||||
_selectedNodes.Remove(_pressNode);
|
_selectedNodes.Remove(_pressNode);
|
||||||
_pressNode.IsSelected = false;
|
_pressNode.IsSelected = false;
|
||||||
_selected = _selectedNodes.FirstOrDefault();
|
SetActiveNode(_selectedNodes.FirstOrDefault());
|
||||||
if (_selected is not null)
|
if (_selected is not null)
|
||||||
{
|
{
|
||||||
PopulateInspector(_selected);
|
PopulateInspector(_selected);
|
||||||
@@ -290,7 +323,7 @@ public partial class MapEditorPage : ContentPage
|
|||||||
{
|
{
|
||||||
_selectedNodes.Add(_pressNode);
|
_selectedNodes.Add(_pressNode);
|
||||||
_pressNode.IsSelected = true;
|
_pressNode.IsSelected = true;
|
||||||
_selected = _pressNode;
|
SetActiveNode(_pressNode);
|
||||||
InspectorScroll.IsVisible = true;
|
InspectorScroll.IsVisible = true;
|
||||||
PopulateInspector(_selected);
|
PopulateInspector(_selected);
|
||||||
}
|
}
|
||||||
@@ -302,7 +335,7 @@ public partial class MapEditorPage : ContentPage
|
|||||||
if (_selectedNodes.Contains(_pressNode))
|
if (_selectedNodes.Contains(_pressNode))
|
||||||
{
|
{
|
||||||
_wasAlreadySelectedInGroup = _selectedNodes.Count > 1;
|
_wasAlreadySelectedInGroup = _selectedNodes.Count > 1;
|
||||||
_selected = _pressNode;
|
SetActiveNode(_pressNode);
|
||||||
PopulateInspector(_selected);
|
PopulateInspector(_selected);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -352,6 +385,26 @@ public partial class MapEditorPage : ContentPage
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_gizmoPart != GizmoPart.None)
|
||||||
|
{
|
||||||
|
if (!_draggingGizmo && Distance(position.Value, _pressPoint) > 4)
|
||||||
|
{
|
||||||
|
_draggingGizmo = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_draggingGizmo)
|
||||||
|
{
|
||||||
|
var (currentWorldX, currentWorldY) = ToWorld(position.Value);
|
||||||
|
var deltaX = currentWorldX - _dragStartWorldPoint.X;
|
||||||
|
var deltaY = currentWorldY - _dragStartWorldPoint.Y;
|
||||||
|
MoveSelectedNodes(deltaX, deltaY,
|
||||||
|
lockX: _gizmoPart == GizmoPart.YAxis,
|
||||||
|
lockY: _gizmoPart == GizmoPart.XAxis);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_pressNode is not null)
|
if (_pressNode is not null)
|
||||||
{
|
{
|
||||||
if (!_draggingNode && Distance(position.Value, _pressPoint) > 4)
|
if (!_draggingNode && Distance(position.Value, _pressPoint) > 4)
|
||||||
@@ -365,19 +418,7 @@ public partial class MapEditorPage : ContentPage
|
|||||||
var deltaX = currentWorldX - _dragStartWorldPoint.X;
|
var deltaX = currentWorldX - _dragStartWorldPoint.X;
|
||||||
var deltaY = currentWorldY - _dragStartWorldPoint.Y;
|
var deltaY = currentWorldY - _dragStartWorldPoint.Y;
|
||||||
|
|
||||||
foreach (var node in _selectedNodes)
|
MoveSelectedNodes(deltaX, deltaY, lockX: false, lockY: false);
|
||||||
{
|
|
||||||
if (_dragStartWorldPositions.TryGetValue(node, out var startPos))
|
|
||||||
{
|
|
||||||
node.X = startPos.X + deltaX;
|
|
||||||
node.Y = startPos.Y + deltaY;
|
|
||||||
node.Doc.IsDirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateDirtyState();
|
|
||||||
SyncPositionEntries();
|
|
||||||
MapView.Invalidate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (_marqueeSelecting)
|
else if (_marqueeSelecting)
|
||||||
@@ -401,13 +442,37 @@ public partial class MapEditorPage : ContentPage
|
|||||||
|
|
||||||
MapView.Invalidate();
|
MapView.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hover highlight for the gizmo when idle (no drag, pan, or marquee).
|
||||||
|
if (_selected is not null && _pressNode is null && !_marqueeSelecting)
|
||||||
|
{
|
||||||
|
var hover = HitTestGizmo(position.Value, _selected);
|
||||||
|
if (hover != _drawable.ActiveGizmoPart)
|
||||||
|
{
|
||||||
|
_drawable.ActiveGizmoPart = hover;
|
||||||
|
MapView.Invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPointerReleased(object? sender, PointerEventArgs e)
|
private void OnPointerReleased(object? sender, PointerEventArgs e)
|
||||||
{
|
{
|
||||||
var position = e.GetPosition(MapView);
|
var position = e.GetPosition(MapView);
|
||||||
|
|
||||||
if (_panning)
|
if (_gizmoPart != GizmoPart.None)
|
||||||
|
{
|
||||||
|
var wasDragging = _draggingGizmo;
|
||||||
|
_gizmoPart = GizmoPart.None;
|
||||||
|
_draggingGizmo = false;
|
||||||
|
_drawable.ActiveGizmoPart = GizmoPart.None;
|
||||||
|
MapView.Invalidate();
|
||||||
|
|
||||||
|
if (wasDragging)
|
||||||
|
{
|
||||||
|
_ = AutoSaveAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_panning)
|
||||||
{
|
{
|
||||||
if (_panModeEnabled && position is not null && Distance(position.Value, _pressPoint) <= 4)
|
if (_panModeEnabled && position is not null && Distance(position.Value, _pressPoint) <= 4)
|
||||||
{
|
{
|
||||||
@@ -458,8 +523,89 @@ public partial class MapEditorPage : ContentPage
|
|||||||
MapView.Invalidate();
|
MapView.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- translation gizmo -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves every node captured at drag start by the given world delta.
|
||||||
|
/// Used by both free node dragging and gizmo axis-constrained dragging.
|
||||||
|
/// </summary>
|
||||||
|
private void MoveSelectedNodes(double deltaX, double deltaY, bool lockX, bool lockY)
|
||||||
|
{
|
||||||
|
foreach (var node in _dragStartWorldPositions.Keys.ToList())
|
||||||
|
{
|
||||||
|
var startPos = _dragStartWorldPositions[node];
|
||||||
|
if (!lockX)
|
||||||
|
{
|
||||||
|
node.X = startPos.X + deltaX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lockY)
|
||||||
|
{
|
||||||
|
node.Y = startPos.Y + deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.Doc.IsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDirtyState();
|
||||||
|
SyncPositionEntries();
|
||||||
|
MapView.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hit-tests the translation gizmo arms of the given node in screen space.
|
||||||
|
/// </summary>
|
||||||
|
private GizmoPart HitTestGizmo(Point screen, MapNode node)
|
||||||
|
{
|
||||||
|
var cx = node.X * _drawable.Scale + _drawable.OffsetX;
|
||||||
|
var cy = node.Y * _drawable.Scale + _drawable.OffsetY;
|
||||||
|
const double arm = MapDrawable.GizmoArmLength;
|
||||||
|
const double tolerance = 10;
|
||||||
|
|
||||||
|
if (Math.Abs(screen.X - cx) <= tolerance && Math.Abs(screen.Y - cy) <= tolerance)
|
||||||
|
{
|
||||||
|
return GizmoPart.Center;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DistanceToSegment(screen.X, screen.Y, cx, cy, cx + arm, cy) <= tolerance)
|
||||||
|
{
|
||||||
|
return GizmoPart.XAxis;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DistanceToSegment(screen.X, screen.Y, cx, cy, cx, cy + arm) <= tolerance)
|
||||||
|
{
|
||||||
|
return GizmoPart.YAxis;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GizmoPart.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double DistanceToSegment(double px, double py,
|
||||||
|
double x1, double y1, double x2, double y2)
|
||||||
|
{
|
||||||
|
var dx = x2 - x1;
|
||||||
|
var dy = y2 - y1;
|
||||||
|
var lenSq = dx * dx + dy * dy;
|
||||||
|
var t = lenSq > 0 ? ((px - x1) * dx + (py - y1) * dy) / lenSq : 0;
|
||||||
|
t = Math.Clamp(t, 0, 1);
|
||||||
|
var qx = x1 + t * dx;
|
||||||
|
var qy = y1 + t * dy;
|
||||||
|
return Math.Sqrt((px - qx) * (px - qx) + (py - qy) * (py - qy));
|
||||||
|
}
|
||||||
|
|
||||||
// ----- selection + inspector -----
|
// ----- selection + inspector -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the active (inspector) node and keeps the translation gizmo
|
||||||
|
/// in sync with it.
|
||||||
|
/// </summary>
|
||||||
|
private void SetActiveNode(MapNode? node)
|
||||||
|
{
|
||||||
|
_selected = node;
|
||||||
|
_drawable.GizmoNode = node;
|
||||||
|
_drawable.ActiveGizmoPart = GizmoPart.None;
|
||||||
|
}
|
||||||
|
|
||||||
private void Select(MapNode? node)
|
private void Select(MapNode? node)
|
||||||
{
|
{
|
||||||
foreach (var n in _nodes)
|
foreach (var n in _nodes)
|
||||||
@@ -468,7 +614,7 @@ public partial class MapEditorPage : ContentPage
|
|||||||
}
|
}
|
||||||
|
|
||||||
_selectedNodes.Clear();
|
_selectedNodes.Clear();
|
||||||
_selected = node;
|
SetActiveNode(node);
|
||||||
|
|
||||||
if (_selected is not null)
|
if (_selected is not null)
|
||||||
{
|
{
|
||||||
@@ -501,14 +647,14 @@ public partial class MapEditorPage : ContentPage
|
|||||||
|
|
||||||
if (_selectedNodes.Count == 0)
|
if (_selectedNodes.Count == 0)
|
||||||
{
|
{
|
||||||
_selected = null;
|
SetActiveNode(null);
|
||||||
InspectorScroll.IsVisible = false;
|
InspectorScroll.IsVisible = false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_selected = primary ?? (_selected is not null && _selectedNodes.Contains(_selected)
|
SetActiveNode(primary ?? (_selected is not null && _selectedNodes.Contains(_selected)
|
||||||
? _selected
|
? _selected
|
||||||
: _selectedNodes.First());
|
: _selectedNodes.First()));
|
||||||
InspectorScroll.IsVisible = true;
|
InspectorScroll.IsVisible = true;
|
||||||
PopulateInspector(_selected);
|
PopulateInspector(_selected);
|
||||||
if (_selectedNodes.Count > 1)
|
if (_selectedNodes.Count > 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user