Io/zerospace converter #1

Merged
JonathanMcCaffrey merged 137 commits from io/zerospace-converter into master 2026-09-29 18:21:24 +00:00
Showing only changes of commit 1a0e584dd2 - Show all commits
@@ -19,6 +19,8 @@ public partial class MapEditorPage : ContentPage
private readonly Dictionary<MapNode, (double X, double Y)> _dragStartWorldPositions = new();
private readonly HashSet<MapNode> _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 -----
/// <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 -----
/// <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)
{
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)