Demos/TarkovInventory/Grid.cs
using System.Collections.Generic;

namespace Sandbox.TarkovInventory;

// Anchor (top-left) + footprint. Rotation is a (W,H) swap about the anchor cell.
public readonly record struct GridRect(int X, int Y, int W, int H)
{
    // Rotate 90°: swap W/H, keep the top-left anchor (pivot is the anchor cell, not the centre).
    public GridRect Rotated() => this with { W = H, H = W };
}

// Pure occupancy model: no Panel, no UI state. T = item key (string id in the demo).
public sealed class Grid<T> where T : notnull
{
    readonly Dictionary<T, GridRect> _placed = new();
    readonly Dictionary<(int x, int y), T> _occ = new();

    public Grid( int cols, int rows )
    {
        Cols = cols;
        Rows = rows;
    }

    public int Cols { get; }
    public int Rows { get; }
    public IReadOnlyDictionary<T, GridRect> Placed => _placed;

    public bool CanPlace( GridRect r ) => CanPlaceCore( r, hasIgnore: false, default! );
    public bool CanPlaceIgnoring( GridRect r, T ignore ) => CanPlaceCore( r, hasIgnore: true, ignore );

    bool CanPlaceCore( GridRect r, bool hasIgnore, T ignore )
    {
        if ( r.W <= 0 || r.H <= 0 ) return false;
        if ( r.X < 0 || r.Y < 0 || r.X + r.W > Cols || r.Y + r.H > Rows ) return false;

        var cmp = EqualityComparer<T>.Default;
        for ( int y = r.Y; y < r.Y + r.H; y++ )
        for ( int x = r.X; x < r.X + r.W; x++ )
            if ( _occ.TryGetValue( (x, y), out var occ ) && !(hasIgnore && cmp.Equals( occ, ignore )) )
                return false;
        return true;
    }

    public bool TryPlace( T item, GridRect r )
    {
        if ( _placed.ContainsKey( item ) ) return false;
        if ( !CanPlace( r ) ) return false;
        Occupy( item, r );
        return true;
    }

    public bool TryMove( T item, int x, int y )
    {
        if ( !_placed.TryGetValue( item, out var cur ) ) return false;
        return TryMove( item, cur with { X = x, Y = y } );
    }

    // Move + optionally reshape (rotation): validates the NEW footprint, ignoring the item's own cells.
    public bool TryMove( T item, GridRect dest )
    {
        if ( !_placed.TryGetValue( item, out var cur ) ) return false;
        if ( !CanPlaceIgnoring( dest, item ) ) return false;
        Free( cur );
        Occupy( item, dest );
        return true;
    }

    public void Remove( T item )
    {
        if ( !_placed.TryGetValue( item, out var r ) ) return;
        Free( r );
        _placed.Remove( item );
    }

    public GridRect? FindFirstFree( int w, int h )
    {
        for ( int y = 0; y + h <= Rows; y++ )
        for ( int x = 0; x + w <= Cols; x++ )
        {
            var r = new GridRect( x, y, w, h );
            if ( CanPlace( r ) ) return r;
        }
        return null;
    }

    public T? ItemAt( int x, int y ) => _occ.TryGetValue( (x, y), out var t ) ? t : default;

    void Occupy( T item, GridRect r )
    {
        _placed[item] = r;
        for ( int y = r.Y; y < r.Y + r.H; y++ )
        for ( int x = r.X; x < r.X + r.W; x++ )
            _occ[(x, y)] = item;
    }

    void Free( GridRect r )
    {
        for ( int y = r.Y; y < r.Y + r.H; y++ )
        for ( int x = r.X; x < r.X + r.W; x++ )
            _occ.Remove( (x, y) );
    }
}