Files
tooll3--t3/Editor/Gui/MagGraph/Ui/MagGraphCanvas.OffscreenIndicators.cs
2026-07-13 13:13:17 +08:00

258 lines
11 KiB
C#

using ImGuiNET;
using T3.Core.DataTypes.Vector;
using T3.Core.Utils;
using T3.Editor.Gui.Styling;
using T3.Editor.Gui.UiHelpers;
namespace T3.Editor.Gui.MagGraph.Ui;
internal sealed partial class MagGraphView
{
/// <summary>
/// When the selection — or an operator highlighted from another panel — lies outside the visible
/// graph area, draws a thin marker that rides the rounded window border at the off-screen box's
/// projected position, so the user can find it without changing zoom or pan.
/// </summary>
private void DrawOffscreenIndicators(ImDrawListPtr drawList)
{
// Selection: steady marker. Iterate the (small) selection list directly rather than the
// item draw loop, which culls off-screen items before its selection check.
var selection = _context.Selector.Selection;
if (selection.Count > 0)
{
var hasBounds = false;
var bounds = new ImRect();
for (var i = 0; i < selection.Count; i++)
{
var s = selection[i];
AccumulateBounds(ref bounds, ref hasBounds, ImRect.RectWithSize(s.PosOnCanvas, s.Size));
}
if (hasBounds && !IsRectVisible(bounds))
DrawEdgeMarker(drawList, bounds, UiColors.ForegroundFull);
}
// Hover: blinking marker for an operator highlighted from another panel. Mirrors the on-node
// highlight condition (FrameStats hover + graph window not hovered) so it reads the same.
if (!IsHovered)
{
var hoveredIds = FrameStats.Last.HoveredIds;
if (hoveredIds.Count > 0)
{
var hasBounds = false;
var bounds = new ImRect();
foreach (var id in hoveredIds)
{
if (_context.Layout.Items.TryGetValue(id, out var item))
AccumulateBounds(ref bounds, ref hasBounds, item.Area);
}
if (hasBounds && !IsRectVisible(bounds))
DrawEdgeMarker(drawList, bounds, UiColors.ForegroundFull.Fade(Blink));
}
}
}
private void DrawEdgeMarker(ImDrawListPtr drawList, in ImRect boundsOnCanvas, Color baseColor)
{
// The marker is a screen-space HUD element pinned to the window edge, so every size is in
// scaled UI pixels (UiScaleFactor) — independent of canvas zoom (CanvasScale).
var windowRect = ImRect.RectWithSize(WindowPos, WindowSize);
var box = TransformRect(boundsOnCanvas);
// Fade in over the first pixels past the edge so the marker doesn't pop the instant the box
// leaves the view. The larger axis overshoot keeps it solid while crossing a corner.
var gapX = MathF.Max(MathF.Max(windowRect.Min.X - box.Max.X, box.Min.X - windowRect.Max.X), 0f);
var gapY = MathF.Max(MathF.Max(windowRect.Min.Y - box.Max.Y, box.Min.Y - windowRect.Max.Y), 0f);
var fade = (MathF.Max(gapX, gapY) / (FadeInDistance * T3Ui.UiScaleFactor)).Clamp(0f, 1f);
if (fade <= 0f)
return;
var color = baseColor.Fade(fade);
var thickness = MarkerThickness * T3Ui.UiScaleFactor;
var minLength = MarkerMinLength * T3Ui.UiScaleFactor;
// The marker rides the rounded window border, inset so the centred stroke sits fully inside.
var inner = windowRect;
inner.Expand(-(EdgePadding * T3Ui.UiScaleFactor + thickness * 0.5f));
var radius = MathF.Min(CornerRadius * T3Ui.UiScaleFactor,
MathF.Min(inner.Max.X - inner.Min.X, inner.Max.Y - inner.Min.Y) * 0.5f);
var perimeter = BorderPerimeter(inner, radius);
// Project the box's four corners onto the border as arc-length positions, then keep the
// smallest arc that contains them all (drop the largest gap between consecutive positions).
// This is one continuous span that slides along edges and wraps corners — no per-edge modes
// whose boundaries could make the line jump.
Span<float> positions = stackalloc float[4];
positions[0] = BorderArcLength(inner, radius, new Vector2(box.Min.X, box.Min.Y));
positions[1] = BorderArcLength(inner, radius, new Vector2(box.Max.X, box.Min.Y));
positions[2] = BorderArcLength(inner, radius, new Vector2(box.Max.X, box.Max.Y));
positions[3] = BorderArcLength(inner, radius, new Vector2(box.Min.X, box.Max.Y));
positions.Sort();
var gapStart = 0;
var largestGap = positions[0] + perimeter - positions[3]; // the wrap-around gap
for (var i = 1; i < 4; i++)
{
var gap = positions[i] - positions[i - 1];
if (gap > largestGap)
{
largestGap = gap;
gapStart = i;
}
}
var spanStart = positions[gapStart];
var spanLength = positions[(gapStart + 3) % 4] - spanStart;
if (spanLength < 0)
spanLength += perimeter;
// Never shorter than the minimum; grow symmetrically along the border so it can't snap inward.
if (spanLength < minLength)
{
spanStart -= (minLength - spanLength) * 0.5f;
spanLength = minLength;
}
var segmentCount = (int)MathF.Max(2, MathF.Ceiling(spanLength / (4 * T3Ui.UiScaleFactor)));
drawList.PathClear();
for (var i = 0; i <= segmentCount; i++)
{
drawList.PathLineTo(BorderPointAt(inner, radius, spanStart + spanLength * ((float)i / segmentCount)));
}
drawList.PathStroke(color, ImDrawFlags.None, thickness);
// Rounded end-caps.
drawList.AddCircleFilled(BorderPointAt(inner, radius, spanStart), thickness * 0.5f, color);
drawList.AddCircleFilled(BorderPointAt(inner, radius, spanStart + spanLength), thickness * 0.5f, color);
}
private static void AccumulateBounds(ref ImRect bounds, ref bool hasBounds, ImRect add)
{
if (!hasBounds)
{
bounds = add;
hasBounds = true;
}
else
{
bounds.Add(add);
}
}
private static float BorderPerimeter(in ImRect inner, float radius)
{
var straightX = inner.Max.X - inner.Min.X - 2 * radius;
var straightY = inner.Max.Y - inner.Min.Y - 2 * radius;
return 2 * straightX + 2 * straightY + 2 * MathF.PI * radius;
}
/// <summary>
/// Arc-length position (clockwise from the top-left corner) of the point on the window's rounded
/// border closest to <paramref name="p"/>. The four straight edges and four quarter-arcs are walked in order.
/// </summary>
private static float BorderArcLength(in ImRect inner, float radius, Vector2 p)
{
var halfPi = MathF.PI * 0.5f;
var straightX = inner.Max.X - inner.Min.X - 2 * radius;
var straightY = inner.Max.Y - inner.Min.Y - 2 * radius;
var quarter = halfPi * radius;
var coreMinX = inner.Min.X + radius;
var coreMaxX = inner.Max.X - radius;
var coreMinY = inner.Min.Y + radius;
var coreMaxY = inner.Max.Y - radius;
var atLeft = p.X < coreMinX;
var atRight = p.X > coreMaxX;
var atTop = p.Y < coreMinY;
var atBottom = p.Y > coreMaxY;
var midX = !atLeft && !atRight;
if (midX && atTop) // top edge
return (p.X - coreMinX).Clamp(0, straightX);
if (atRight && atTop) // top-right arc
return straightX + (MathF.Atan2(p.Y - coreMinY, p.X - coreMaxX).Clamp(-halfPi, 0) + halfPi) * radius;
if (atRight && !atBottom) // right edge
return straightX + quarter + (p.Y - coreMinY).Clamp(0, straightY);
if (atRight && atBottom) // bottom-right arc
return straightX + quarter + straightY + MathF.Atan2(p.Y - coreMaxY, p.X - coreMaxX).Clamp(0, halfPi) * radius;
if (midX && atBottom) // bottom edge (right to left)
return straightX + quarter + straightY + quarter + (coreMaxX - p.X).Clamp(0, straightX);
if (atLeft && atBottom) // bottom-left arc
return straightX + quarter + straightY + quarter + straightX
+ (MathF.Atan2(p.Y - coreMaxY, p.X - coreMinX).Clamp(halfPi, MathF.PI) - halfPi) * radius;
if (atLeft && !atTop) // left edge (bottom to top)
return straightX + quarter + straightY + quarter + straightX + quarter + (coreMaxY - p.Y).Clamp(0, straightY);
// top-left arc (atLeft && atTop)
var angle = MathF.Atan2(p.Y - coreMinY, p.X - coreMinX);
if (angle < 0)
angle += 2 * MathF.PI;
return straightX + quarter + straightY + quarter + straightX + quarter + straightY
+ (angle.Clamp(MathF.PI, MathF.PI + halfPi) - MathF.PI) * radius;
}
/// <summary>Inverse of <see cref="BorderArcLength"/>: the border point at arc-length <paramref name="s"/>.</summary>
private static Vector2 BorderPointAt(in ImRect inner, float radius, float s)
{
var halfPi = MathF.PI * 0.5f;
var straightX = inner.Max.X - inner.Min.X - 2 * radius;
var straightY = inner.Max.Y - inner.Min.Y - 2 * radius;
var quarter = halfPi * radius;
var perimeter = 2 * straightX + 2 * straightY + 4 * quarter;
s %= perimeter;
if (s < 0)
s += perimeter;
if (s <= straightX) // top edge
return new Vector2(inner.Min.X + radius + s, inner.Min.Y);
s -= straightX;
if (s <= quarter) // top-right arc
return ArcPoint(new Vector2(inner.Max.X - radius, inner.Min.Y + radius), radius, -halfPi + s / radius);
s -= quarter;
if (s <= straightY) // right edge
return new Vector2(inner.Max.X, inner.Min.Y + radius + s);
s -= straightY;
if (s <= quarter) // bottom-right arc
return ArcPoint(new Vector2(inner.Max.X - radius, inner.Max.Y - radius), radius, s / radius);
s -= quarter;
if (s <= straightX) // bottom edge
return new Vector2(inner.Max.X - radius - s, inner.Max.Y);
s -= straightX;
if (s <= quarter) // bottom-left arc
return ArcPoint(new Vector2(inner.Min.X + radius, inner.Max.Y - radius), radius, halfPi + s / radius);
s -= quarter;
if (s <= straightY) // left edge
return new Vector2(inner.Min.X, inner.Max.Y - radius - s);
s -= straightY;
// top-left arc
return ArcPoint(new Vector2(inner.Min.X + radius, inner.Min.Y + radius), radius, MathF.PI + s / radius);
}
private static Vector2 ArcPoint(Vector2 center, float radius, float angle)
{
return center + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * radius;
}
private const float EdgePadding = 3;
private const float MarkerThickness = 2;
private const float MarkerMinLength = 14;
private const float CornerRadius = 8;
private const float FadeInDistance = 50;
}