diff --git a/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs b/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs index ae2286b..4f907f5 100644 --- a/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs +++ b/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs @@ -19,6 +19,8 @@ public partial class MapEditorPage : ContentPage private readonly Dictionary _dragStartWorldPositions = new(); private readonly HashSet _preMarqueeSelected = new(); private bool _draggingNode; + private GizmoPart _gizmoPart = GizmoPart.None; + private bool _draggingGizmo; private bool _marqueeSelecting; private bool _panning; private bool _panModeEnabled; @@ -266,6 +268,37 @@ public partial class MapEditorPage : ContentPage 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); if (_pressNode is not null) @@ -276,7 +309,7 @@ public partial class MapEditorPage : ContentPage { _selectedNodes.Remove(_pressNode); _pressNode.IsSelected = false; - _selected = _selectedNodes.FirstOrDefault(); + SetActiveNode(_selectedNodes.FirstOrDefault()); if (_selected is not null) { PopulateInspector(_selected); @@ -290,7 +323,7 @@ public partial class MapEditorPage : ContentPage { _selectedNodes.Add(_pressNode); _pressNode.IsSelected = true; - _selected = _pressNode; + SetActiveNode(_pressNode); InspectorScroll.IsVisible = true; PopulateInspector(_selected); } @@ -302,7 +335,7 @@ public partial class MapEditorPage : ContentPage if (_selectedNodes.Contains(_pressNode)) { _wasAlreadySelectedInGroup = _selectedNodes.Count > 1; - _selected = _pressNode; + SetActiveNode(_pressNode); PopulateInspector(_selected); } else @@ -352,6 +385,26 @@ public partial class MapEditorPage : ContentPage 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 (!_draggingNode && Distance(position.Value, _pressPoint) > 4) @@ -365,19 +418,7 @@ public partial class MapEditorPage : ContentPage var deltaX = currentWorldX - _dragStartWorldPoint.X; var deltaY = currentWorldY - _dragStartWorldPoint.Y; - foreach (var node in _selectedNodes) - { - 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(); + MoveSelectedNodes(deltaX, deltaY, lockX: false, lockY: false); } } else if (_marqueeSelecting) @@ -401,13 +442,37 @@ public partial class MapEditorPage : ContentPage 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) { 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) { @@ -458,8 +523,89 @@ public partial class MapEditorPage : ContentPage MapView.Invalidate(); } + // ----- translation gizmo ----- + + /// + /// Moves every node captured at drag start by the given world delta. + /// Used by both free node dragging and gizmo axis-constrained dragging. + /// + 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(); + } + + /// + /// Hit-tests the translation gizmo arms of the given node in screen space. + /// + 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 ----- + /// + /// Sets the active (inspector) node and keeps the translation gizmo + /// in sync with it. + /// + private void SetActiveNode(MapNode? node) + { + _selected = node; + _drawable.GizmoNode = node; + _drawable.ActiveGizmoPart = GizmoPart.None; + } + private void Select(MapNode? node) { foreach (var n in _nodes) @@ -468,7 +614,7 @@ public partial class MapEditorPage : ContentPage } _selectedNodes.Clear(); - _selected = node; + SetActiveNode(node); if (_selected is not null) { @@ -501,14 +647,14 @@ public partial class MapEditorPage : ContentPage if (_selectedNodes.Count == 0) { - _selected = null; + SetActiveNode(null); InspectorScroll.IsVisible = false; } else { - _selected = primary ?? (_selected is not null && _selectedNodes.Contains(_selected) + SetActiveNode(primary ?? (_selected is not null && _selectedNodes.Contains(_selected) ? _selected - : _selectedNodes.First()); + : _selectedNodes.First())); InspectorScroll.IsVisible = true; PopulateInspector(_selected); if (_selectedNodes.Count > 1)