Editor/Prism/Model/GraphGroup.cs

Editor-side model for a diagram/group box in the Prism editor. Stores id, title, description, position, size, color and layer, computes its rect with a minimum size, membership by geometric containment, can move itself and members, fit to a set of points, and clone itself.

File Access
using Editor.Prism.Core;

namespace Editor.Prism.Model;

/// <summary>
/// A titled box drawn behind a set of nodes. Purely organisational — a group never participates in
/// compilation — but it is a first-class document entity so it survives copy/paste, undo and save.
/// <para>
/// Membership is geometric rather than stored: a node belongs to the group whose rectangle contains
/// it. That keeps the document small, makes dragging a node in or out free, and means a group can
/// never end up referencing a node that no longer exists.
/// </para>
/// </summary>
public sealed class GraphGroup
{
	/// <summary>Build an empty group with a fresh id.</summary>
	public GraphGroup()
	{
		Id = Ids.NewShortId();
	}

	/// <summary>Build a titled group covering a rectangle, with a fresh id.</summary>
	public GraphGroup( string title, Vector2 position, Vector2 size ) : this()
	{
		Title = title;
		Position = position;
		Size = size;
	}

	/// <summary>Stable id, minted once and never rewritten.</summary>
	public string Id { get; set; }

	/// <summary>Header text.</summary>
	public string Title { get; set; } = "Group";

	/// <summary>Optional body text drawn under the header.</summary>
	public string Description { get; set; }

	/// <summary>Top-left corner in scene space.</summary>
	public Vector2 Position { get; set; }

	/// <summary>Width and height in scene space. Clamped to <see cref="MinSize"/> when read as a rect.</summary>
	public Vector2 Size { get; set; } = new( 320f, 200f );

	/// <summary>Named colour from <see cref="Palette"/>. Unknown names fall back to <c>Blue</c>.</summary>
	public string Color { get; set; } = "Blue";

	/// <summary>Stacking order among overlapping groups. Higher draws in front.</summary>
	public int Layer { get; set; }

	/// <summary>The group's scene rectangle, with the minimum size enforced.</summary>
	public Rect Rect
	{
		get => new( Position, new Vector2( Math.Max( Size.x, MinSize.x ), Math.Max( Size.y, MinSize.y ) ) );
		set
		{
			Position = value.Position;
			Size = value.Size;
		}
	}

	/// <summary>True when a scene-space point lies inside the group.</summary>
	public bool Contains( Vector2 point )
	{
		var rect = Rect;

		return point.x >= rect.Left && point.x <= rect.Left + rect.Width &&
			point.y >= rect.Top && point.y <= rect.Top + rect.Height;
	}

	/// <summary>True when a node's card origin lies inside the group.</summary>
	public bool Contains( PrismNode node ) => node is not null && Contains( node.Position );

	/// <summary>Every node of a graph whose position lies inside this group.</summary>
	public IEnumerable<PrismNode> Members( IPrismGraph graph )
	{
		if ( graph?.Nodes is null ) yield break;

		foreach ( var node in graph.Nodes )
		{
			if ( Contains( node ) ) yield return node;
		}
	}

	/// <summary>Move the group and, optionally, everything inside it.</summary>
	public void MoveBy( Vector2 delta, IPrismGraph graph = null )
	{
		if ( graph is not null )
		{
			var members = Members( graph ).ToArray();

			foreach ( var node in members )
			{
				node.Position += delta;
			}
		}

		Position += delta;
	}

	/// <summary>
	/// Resize the group so it encloses the given scene positions plus padding. Does nothing when the
	/// set is empty, so "fit to selection" with nothing selected is a no-op rather than a collapse.
	/// </summary>
	public void FitTo( IEnumerable<Vector2> positions, Vector2 padding )
	{
		if ( positions is null ) return;

		var any = false;
		float minX = 0f, minY = 0f, maxX = 0f, maxY = 0f;

		foreach ( var p in positions )
		{
			if ( !any )
			{
				minX = maxX = p.x;
				minY = maxY = p.y;
				any = true;
				continue;
			}

			minX = Math.Min( minX, p.x );
			minY = Math.Min( minY, p.y );
			maxX = Math.Max( maxX, p.x );
			maxY = Math.Max( maxY, p.y );
		}

		if ( !any ) return;

		Position = new Vector2( minX - padding.x, minY - padding.y );
		Size = new Vector2( maxX - minX + padding.x * 2f, maxY - minY + padding.y * 2f );
	}

	/// <summary>Deep copy, keeping the same id.</summary>
	public GraphGroup Clone() => new()
	{
		Id = Id,
		Title = Title,
		Description = Description,
		Position = Position,
		Size = Size,
		Color = Color,
		Layer = Layer
	};

	/// <summary>Deep copy with a freshly minted id.</summary>
	public GraphGroup CloneWithNewId()
	{
		var copy = Clone();
		copy.Id = Ids.NewShortId();
		return copy;
	}

	/// <inheritdoc/>
	public override string ToString() => $"Group '{Title}' {Rect}";

	/// <summary>The smallest a group may be, so it stays grabbable.</summary>
	public static readonly Vector2 MinSize = new( 128f, 96f );

	/// <summary>The named colours a group or note may use. The UI resolves these against the theme.</summary>
	public static readonly IReadOnlyList<string> Palette = new[]
	{
		"Blue", "Purple", "Green", "Yellow", "Orange", "Red", "Teal", "Grey"
	};
}