using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace Theriapolis.Tools.Commands;
///
/// tile-analyze --dir <path> [--sheet <out.png>]
/// tile-analyze --files <f1.png> <f2.png> ... [--sheet <out.png>]
///
/// Reports per-tile diagnostics for a folder of 32×32 PNG tiles
/// (typically a Pixellab tiles_pro download). Used to vet tile quality
/// before saving picks into Content/Gfx/tactical/.
///
/// What it checks:
///
/// • Border edges (0–4): how many of the four perimeter rows/cols are
/// ≥80% "dark uniform" pixels (max channel ≤ 95 AND max - min ≤ 25). Catches
/// the hard near-black or dark-grey frames that the regular create_tiles_pro
/// path bakes around every tile. Should be 0 for a clean surface tile.
///
/// • Opaque %: fraction of pixels with α ≥ 128. Catches the failure
/// mode where a "marsh" or other prompt produces transparent decoration
/// sprites instead of edge-to-edge surface tiles. Surface tiles want ~100%;
/// decoration sprites are typically 30–70%.
///
/// • Shadow scores (top/bot/lef/rig): brightness drop on each edge
/// versus the interior average. Positive = edge is darker than interior.
/// The pseudo-3D shading that tile_view: "low top-down" bakes in
/// shows up here as a +50 to +90 drop on the bottom and right edges that
/// the border detector misses (the colors aren't black-uniform, just
/// darker). Anything > 30 is a red flag for tiling: adjacent tiles will
/// show a visible diagonal grid of dark seams.
///
/// Optionally writes a labeled 4×-upscaled contact sheet so you can present
/// the batch to a user for picks. Labels turn ORANGE for any tile that fails
/// any check (borders > 0, opaque < 95%, or shadow > threshold).
///
/// See theriapolis-tile-generation-handoff.md for the full Pixellab
/// MCP workflow this tool slots into.
///
public static class TileAnalyze
{
public static int Run(string[] args)
{
string? dir = null;
string? sheetOut = null;
var explicitFiles = new List();
int shadowThreshold = 30;
int upscale = 4;
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--dir":
if (i + 1 < args.Length) dir = args[++i];
break;
case "--sheet":
if (i + 1 < args.Length) sheetOut = args[++i];
break;
case "--shadow-threshold":
if (i + 1 < args.Length) shadowThreshold = int.Parse(args[++i]);
break;
case "--upscale":
if (i + 1 < args.Length) upscale = int.Parse(args[++i]);
break;
case "--files":
while (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
explicitFiles.Add(args[++i]);
break;
}
}
List files;
if (dir is not null)
{
if (!Directory.Exists(dir))
{
Console.Error.WriteLine($"Directory not found: {dir}");
return 1;
}
// Sort numerically when the filename is tile_N.png — common Pixellab layout.
files = Directory.EnumerateFiles(dir, "*.png")
.OrderBy(NumericOrderKey)
.ToList();
}
else if (explicitFiles.Count > 0)
{
files = explicitFiles;
}
else
{
PrintHelp();
return 1;
}
if (files.Count == 0) { Console.Error.WriteLine("No PNGs found."); return 1; }
var stats = new List(files.Count);
Console.WriteLine($"Analyzing {files.Count} tiles (shadow threshold {shadowThreshold})\n");
Console.WriteLine($"{"file",-32} | brd | op% | int | top bot lef rig | verdict");
Console.WriteLine(new string('-', 90));
foreach (var path in files)
{
using var img = Image.Load(path);
var st = AnalyzeTile(path, img);
stats.Add(st);
int maxShadow = Math.Max(Math.Max(Math.Abs(st.TopDiff), Math.Abs(st.BotDiff)),
Math.Max(Math.Abs(st.LefDiff), Math.Abs(st.RigDiff)));
string verdict = (st.BorderEdges == 0 && st.OpaquePct >= 95 && maxShadow <= shadowThreshold)
? "ok"
: "⚠";
Console.WriteLine(
$"{Path.GetFileNameWithoutExtension(path),-32} | {st.BorderEdges} | {st.OpaquePct,3}% | {st.InteriorBrightness,3} |" +
$" {st.TopDiff,4} {st.BotDiff,4} {st.LefDiff,4} {st.RigDiff,4} | {verdict}");
}
if (sheetOut is not null)
{
BuildContactSheet(files, stats, sheetOut, upscale, shadowThreshold);
Console.WriteLine($"\nContact sheet: {sheetOut}");
}
return 0;
}
// Numeric key so "tile_2" sorts before "tile_10".
private static (int, string) NumericOrderKey(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
int u = name.LastIndexOf('_');
if (u >= 0 && int.TryParse(name[(u + 1)..], out var n)) return (n, name);
return (int.MaxValue, name);
}
// ── Detector logic ────────────────────────────────────────────────────
public readonly record struct TileStat(
int BorderEdges,
int OpaquePct,
int InteriorBrightness,
int TopDiff,
int BotDiff,
int LefDiff,
int RigDiff);
public static TileStat AnalyzeTile(string path, Image img)
{
int border = CountBorderEdges(img);
int opaque = CountOpaqueFraction(img);
var (interior, top, bot, lef, rig) = ShadowScores(img);
return new TileStat(border, opaque, interior, top, bot, lef, rig);
}
///
/// "Border" pixel: alpha ≥ 128 AND max channel ≤ 95 AND (max - min) ≤ 25.
/// Catches the dark-uniform frames Pixellab bakes around tiles.
///
private static bool IsBorderPixel(Rgba32 p)
{
if (p.A < 128) return false;
int max = Math.Max(p.R, Math.Max(p.G, p.B));
int min = Math.Min(p.R, Math.Min(p.G, p.B));
return max <= 95 && (max - min) <= 25;
}
/// Number of edges (0–4) where ≥80% of perimeter pixels are border-pixels.
public static int CountBorderEdges(Image img)
{
int w = img.Width, h = img.Height;
int top = 0, bot = 0, lef = 0, rig = 0;
for (int x = 0; x < w; x++)
{
if (IsBorderPixel(img[x, 0])) top++;
if (IsBorderPixel(img[x, h - 1])) bot++;
}
for (int y = 0; y < h; y++)
{
if (IsBorderPixel(img[0, y])) lef++;
if (IsBorderPixel(img[w - 1, y])) rig++;
}
int edges = 0;
if (top >= w * 0.8) edges++;
if (bot >= w * 0.8) edges++;
if (lef >= h * 0.8) edges++;
if (rig >= h * 0.8) edges++;
return edges;
}
/// Percent (0–100) of pixels with alpha ≥ 128.
public static int CountOpaqueFraction(Image img)
{
int total = img.Width * img.Height, opaque = 0;
for (int y = 0; y < img.Height; y++)
for (int x = 0; x < img.Width; x++)
if (img[x, y].A >= 128) opaque++;
return (int)(opaque * 100.0 / total);
}
///
/// Edge-vs-interior brightness drop for each side. Positive value = edge
/// is darker than interior (= bad: baked-in shadow gradient).
/// Interior excludes a 4-pixel margin so it's the "true" middle.
///
public static (int interior, int topDiff, int botDiff, int lefDiff, int rigDiff) ShadowScores(Image img)
{
int w = img.Width, h = img.Height;
long ir = 0, ig = 0, ib = 0, ic = 0;
for (int y = 4; y < h - 4; y++)
for (int x = 4; x < w - 4; x++)
{
var p = img[x, y]; if (p.A < 128) continue;
ir += p.R; ig += p.G; ib += p.B; ic++;
}
if (ic == 0) return (0, 0, 0, 0, 0);
int avgR = (int)(ir / ic), avgG = (int)(ig / ic), avgB = (int)(ib / ic);
int interior = (avgR + avgG + avgB) / 3;
int EdgeBrightness(Func sel, int len)
{
long er = 0, eg = 0, eb = 0;
for (int i = 0; i < len; i++)
{
var (x, y) = sel(i);
var p = img[x, y];
er += p.R; eg += p.G; eb += p.B;
}
return ((int)(er / len) + (int)(eg / len) + (int)(eb / len)) / 3;
}
int top = EdgeBrightness(i => (i, 0), w);
int bot = EdgeBrightness(i => (i, h - 1), w);
int lef = EdgeBrightness(i => (0, i), h);
int rig = EdgeBrightness(i => (w - 1, i), h);
return (interior, interior - top, interior - bot, interior - lef, interior - rig);
}
// ── Contact sheet ─────────────────────────────────────────────────────
private static void BuildContactSheet(
List files, List stats, string outPath,
int upscale, int shadowThreshold)
{
int n = files.Count;
int cols = (int)Math.Ceiling(Math.Sqrt(n));
int rows = (int)Math.Ceiling((double)n / cols);
int cell = 32, gap = 6, label = 30;
int side = cell * upscale;
int sheetW = side * cols + (cols + 1) * gap;
int sheetH = (side + label) * rows + (rows + 1) * gap;
var family = SystemFonts.Get("Consolas");
var font = family.CreateFont(10f, FontStyle.Regular);
using var sheet = new Image(sheetW, sheetH, new Rgba32(40, 40, 40));
for (int i = 0; i < n; i++)
{
using var t = Image.Load(files[i]);
t.Mutate(x => x.Resize(side, side, KnownResamplers.NearestNeighbor));
int cx = (i % cols) * (side + gap) + gap;
int cy = (i / cols) * (side + label + gap) + gap;
sheet.Mutate(x => x.DrawImage(t, new Point(cx, cy), 1f));
var st = stats[i];
int maxShadow = Math.Max(Math.Max(Math.Abs(st.TopDiff), Math.Abs(st.BotDiff)),
Math.Max(Math.Abs(st.LefDiff), Math.Abs(st.RigDiff)));
bool ok = st.BorderEdges == 0 && st.OpaquePct >= 95 && maxShadow <= shadowThreshold;
var col = ok ? Color.LightGreen : Color.Orange;
string idx = Path.GetFileNameWithoutExtension(files[i]);
sheet.Mutate(x => x.DrawText(
$"{idx} b:{st.BorderEdges} op:{st.OpaquePct}%",
font, col, new PointF(cx + 4, cy + side + 2)));
sheet.Mutate(x => x.DrawText(
$"sh t/b/l/r: {st.TopDiff,3} {st.BotDiff,3} {st.LefDiff,3} {st.RigDiff,3}",
font, col, new PointF(cx + 4, cy + side + 14)));
}
sheet.Save(outPath);
}
private static void PrintHelp()
{
Console.WriteLine("Usage:");
Console.WriteLine(" tile-analyze --dir [--sheet ] [--shadow-threshold N] [--upscale N]");
Console.WriteLine(" tile-analyze --files ... [--sheet ]");
Console.WriteLine();
Console.WriteLine("Reports border edges, opaque %, and shadow gradients per tile,");
Console.WriteLine("optionally writing a labeled contact sheet for visual review.");
}
}