작업 표시줄과 트레이에서 AX Copilot 아이콘이 다른 앱보다 작게 보이던 원인은 icon.ico 내부 여백이 커서 실제 도형 점유율이 낮았기 때문이다. 현재 4다이아몬드 계열 형태는 유지한 채 내부 여백을 줄이고 캔버스를 더 넓게 쓰는 새 멀티사이즈 아이콘으로 자산을 재생성했다. 아이콘 생성 경로도 함께 정리했다. tools/IconGenerator는 현재 AX 아이콘 스타일을 기본으로 생성하고 16 20 24 32 40 48 64 128 256 프레임을 포함하도록 바꿨다. src/AxCopilot/Assets/diamond_pixel.svg도 같은 비율로 맞춰 소스 SVG와 실제 ico 자산이 덜 어긋나게 정리했다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_icon_size\ -p:IntermediateOutputPath=obj\verify_icon_size\ / 경고 0 오류 0 검증: System.Drawing.Icon 확인 결과 16 20 24 32 프레임이 요청 크기 그대로 로드됨
329 lines
11 KiB
C#
329 lines
11 KiB
C#
using System.Drawing;
|
|
using System.Drawing.Drawing2D;
|
|
using System.Drawing.Imaging;
|
|
|
|
var options = IconGeneratorOptions.Parse(args);
|
|
var pngFrames = new List<byte[]>();
|
|
|
|
foreach (var size in options.Sizes)
|
|
{
|
|
using var bitmap = options.Style switch
|
|
{
|
|
IconStyle.LegacyGem => DrawLegacyGemDiamond(size),
|
|
_ => DrawAxDiamondPixel(size),
|
|
};
|
|
|
|
using var stream = new MemoryStream();
|
|
bitmap.Save(stream, ImageFormat.Png);
|
|
pngFrames.Add(stream.ToArray());
|
|
Console.WriteLine($" {size}x{size} OK");
|
|
|
|
if (options.PreviewSize == size && !string.IsNullOrWhiteSpace(options.PreviewPath))
|
|
bitmap.Save(options.PreviewPath!, ImageFormat.Png);
|
|
}
|
|
|
|
CreateIco(pngFrames, options.Sizes, options.OutputPath);
|
|
Console.WriteLine($"Icon saved: {options.OutputPath}");
|
|
|
|
static Bitmap DrawAxDiamondPixel(int size)
|
|
{
|
|
var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb);
|
|
using var g = Graphics.FromImage(bitmap);
|
|
g.Clear(Color.Transparent);
|
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
|
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
|
g.CompositingQuality = CompositingQuality.HighQuality;
|
|
|
|
var canvas = size;
|
|
var groupSpan = canvas * 0.55f;
|
|
var tileSize = canvas * 0.245f;
|
|
var gap = Math.Max(1f, groupSpan - tileSize * 2f);
|
|
var left = (canvas - groupSpan) / 2f;
|
|
var top = left;
|
|
var radius = Math.Max(2f, tileSize * 0.16f);
|
|
var center = new PointF(canvas / 2f, canvas / 2f);
|
|
|
|
var tiles = new[]
|
|
{
|
|
new IconTile(
|
|
new RectangleF(left, top, tileSize, tileSize),
|
|
Color.FromArgb(0x74, 0x8E, 0xFF),
|
|
Color.FromArgb(0x5B, 0x70, 0xF7)),
|
|
new IconTile(
|
|
new RectangleF(left + tileSize + gap, top, tileSize, tileSize),
|
|
Color.FromArgb(0x7C, 0xF0, 0x88),
|
|
Color.FromArgb(0x42, 0xD6, 0x63)),
|
|
new IconTile(
|
|
new RectangleF(left, top + tileSize + gap, tileSize, tileSize),
|
|
Color.FromArgb(0x64, 0xDB, 0x8B),
|
|
Color.FromArgb(0x36, 0xC6, 0x78)),
|
|
new IconTile(
|
|
new RectangleF(left + tileSize + gap, top + tileSize + gap, tileSize, tileSize),
|
|
Color.FromArgb(0xFF, 0x6C, 0x84),
|
|
Color.FromArgb(0xF2, 0x47, 0x69)),
|
|
};
|
|
|
|
var shadowOffset = Math.Max(0.45f, canvas * 0.02f);
|
|
using var shadowBrush = new SolidBrush(Color.FromArgb((int)(255 * 0.16f), 0x2A, 0x34, 0x56));
|
|
foreach (var tile in tiles)
|
|
{
|
|
using var shadowPath = CreateRoundedRectangle(new RectangleF(
|
|
tile.Bounds.X + shadowOffset,
|
|
tile.Bounds.Y + shadowOffset,
|
|
tile.Bounds.Width,
|
|
tile.Bounds.Height), radius);
|
|
using var matrix = new Matrix();
|
|
matrix.RotateAt(45f, center);
|
|
shadowPath.Transform(matrix);
|
|
g.FillPath(shadowBrush, shadowPath);
|
|
}
|
|
|
|
foreach (var tile in tiles)
|
|
{
|
|
using var path = CreateRoundedRectangle(tile.Bounds, radius);
|
|
using var matrix = new Matrix();
|
|
matrix.RotateAt(45f, center);
|
|
path.Transform(matrix);
|
|
|
|
using var brush = new LinearGradientBrush(tile.Bounds, tile.Highlight, tile.Base, 45f, true);
|
|
g.FillPath(brush, path);
|
|
|
|
using var outline = new Pen(Color.FromArgb((int)(255 * 0.26f), 255, 255, 255), Math.Max(0.7f, canvas / 60f))
|
|
{
|
|
LineJoin = LineJoin.Round
|
|
};
|
|
g.DrawPath(outline, path);
|
|
}
|
|
|
|
using var shineBrush = new SolidBrush(Color.FromArgb((int)(255 * 0.18f), 255, 255, 255));
|
|
var shineSize = Math.Max(1.2f, canvas * 0.07f);
|
|
foreach (var point in new[]
|
|
{
|
|
new PointF(canvas * 0.30f, canvas * 0.28f),
|
|
new PointF(canvas * 0.67f, canvas * 0.30f),
|
|
new PointF(canvas * 0.34f, canvas * 0.66f),
|
|
})
|
|
{
|
|
g.FillEllipse(shineBrush, point.X, point.Y, shineSize, shineSize);
|
|
}
|
|
|
|
return bitmap;
|
|
}
|
|
|
|
static Bitmap DrawLegacyGemDiamond(int size)
|
|
{
|
|
var bmp = new Bitmap(size, size, PixelFormat.Format32bppArgb);
|
|
using var g = Graphics.FromImage(bmp);
|
|
g.SmoothingMode = SmoothingMode.HighQuality;
|
|
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
|
g.Clear(Color.Transparent);
|
|
|
|
var s = size;
|
|
var cx = s / 2f;
|
|
var tableL = s * 0.22f;
|
|
var tableR = s * 0.78f;
|
|
var tableY = s * 0.18f;
|
|
var girdleL = s * 0.06f;
|
|
var girdleR = s * 0.94f;
|
|
var girdleY = s * 0.40f;
|
|
var culetX = cx;
|
|
var culetY = s * 0.92f;
|
|
|
|
PointF tl = new(tableL, tableY);
|
|
PointF tr = new(tableR, tableY);
|
|
PointF gl = new(girdleL, girdleY);
|
|
PointF gr = new(girdleR, girdleY);
|
|
PointF bt = new(culetX, culetY);
|
|
PointF cm = new(cx, girdleY);
|
|
PointF ct = new(cx, tableY);
|
|
|
|
var blue = Color.FromArgb(50, 110, 230);
|
|
var blueBright = Color.FromArgb(80, 150, 255);
|
|
var green = Color.FromArgb(60, 200, 80);
|
|
var greenBright = Color.FromArgb(100, 235, 110);
|
|
var red = Color.FromArgb(230, 50, 65);
|
|
var redBright = Color.FromArgb(255, 90, 100);
|
|
var greenDk = Color.FromArgb(45, 170, 75);
|
|
var greenDkBr = Color.FromArgb(80, 210, 100);
|
|
|
|
FillPolygon(g, new[] { tl, gl, cm, ct }, blueBright, blue);
|
|
FillPolygon(g, new[] { tr, ct, cm, gr }, greenBright, green);
|
|
FillPolygon(g, new[] { gl, cm, bt }, redBright, red);
|
|
FillPolygon(g, new[] { cm, gr, bt }, greenDkBr, greenDk);
|
|
|
|
using var crownShadow = new SolidBrush(Color.FromArgb(25, 0, 0, 0));
|
|
g.FillPolygon(crownShadow, new[] { tl, gl, new PointF(cx * 0.7f, girdleY * 0.85f) });
|
|
|
|
using var pavHighlight = new SolidBrush(Color.FromArgb(20, 255, 255, 255));
|
|
g.FillPolygon(pavHighlight, new[] { cm, bt, new PointF(cx - s * 0.08f, s * 0.62f) });
|
|
|
|
using var facetPen = new Pen(Color.FromArgb(180, 255, 255, 255), Math.Max(0.8f, s / 140f))
|
|
{
|
|
LineJoin = LineJoin.Round,
|
|
StartCap = LineCap.Round,
|
|
EndCap = LineCap.Round
|
|
};
|
|
|
|
g.DrawLine(facetPen, tl, tr);
|
|
g.DrawLine(facetPen, tl, gl);
|
|
g.DrawLine(facetPen, tr, gr);
|
|
g.DrawLine(facetPen, ct, cm);
|
|
g.DrawLine(facetPen, tl, cm);
|
|
g.DrawLine(facetPen, tr, cm);
|
|
g.DrawLine(facetPen, tl, new PointF(girdleL + (cx - girdleL) * 0.5f, girdleY));
|
|
g.DrawLine(facetPen, tr, new PointF(cx + (girdleR - cx) * 0.5f, girdleY));
|
|
g.DrawLine(facetPen, gl, gr);
|
|
g.DrawLine(facetPen, gl, bt);
|
|
g.DrawLine(facetPen, gr, bt);
|
|
g.DrawLine(facetPen, cm, bt);
|
|
|
|
var crossY = girdleY + (culetY - girdleY) * 0.45f;
|
|
PointF crossL = new(girdleL + (culetX - girdleL) * 0.45f, crossY);
|
|
PointF crossR = new(girdleR - (girdleR - culetX) * 0.45f, crossY);
|
|
g.DrawLine(facetPen, gl, crossR);
|
|
g.DrawLine(facetPen, gr, crossL);
|
|
g.DrawLine(facetPen, new PointF(girdleL + (cx - girdleL) * 0.5f, girdleY), bt);
|
|
g.DrawLine(facetPen, new PointF(cx + (girdleR - cx) * 0.5f, girdleY), bt);
|
|
|
|
using var outlinePen = new Pen(Color.FromArgb(220, 255, 255, 255), Math.Max(1.2f, s / 100f))
|
|
{
|
|
LineJoin = LineJoin.Round
|
|
};
|
|
g.DrawPolygon(outlinePen, new[] { tl, tr, gr, bt, gl });
|
|
|
|
using var tableHighlight = new LinearGradientBrush(
|
|
tl,
|
|
new PointF(cx, girdleY),
|
|
Color.FromArgb(45, 255, 255, 255),
|
|
Color.Transparent);
|
|
g.FillPolygon(tableHighlight, new[] { tl, tr, ct });
|
|
|
|
return bmp;
|
|
}
|
|
|
|
static GraphicsPath CreateRoundedRectangle(RectangleF bounds, float radius)
|
|
{
|
|
var path = new GraphicsPath();
|
|
var diameter = radius * 2f;
|
|
|
|
if (radius <= 0.01f)
|
|
{
|
|
path.AddRectangle(bounds);
|
|
return path;
|
|
}
|
|
|
|
path.AddArc(bounds.Left, bounds.Top, diameter, diameter, 180, 90);
|
|
path.AddArc(bounds.Right - diameter, bounds.Top, diameter, diameter, 270, 90);
|
|
path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90);
|
|
path.AddArc(bounds.Left, bounds.Bottom - diameter, diameter, diameter, 90, 90);
|
|
path.CloseFigure();
|
|
return path;
|
|
}
|
|
|
|
static void FillPolygon(Graphics g, PointF[] points, Color topColor, Color bottomColor)
|
|
{
|
|
var minX = points.Min(p => p.X);
|
|
var maxX = points.Max(p => p.X);
|
|
var minY = points.Min(p => p.Y);
|
|
var maxY = points.Max(p => p.Y);
|
|
var rect = new RectangleF(minX, minY, Math.Max(1, maxX - minX), Math.Max(1, maxY - minY));
|
|
|
|
using var brush = new LinearGradientBrush(rect, topColor, bottomColor, LinearGradientMode.Vertical);
|
|
g.FillPolygon(brush, points);
|
|
}
|
|
|
|
static void CreateIco(IReadOnlyList<byte[]> pngs, IReadOnlyList<int> sizes, string outputPath)
|
|
{
|
|
using var memoryStream = new MemoryStream();
|
|
using var writer = new BinaryWriter(memoryStream);
|
|
|
|
writer.Write((short)0);
|
|
writer.Write((short)1);
|
|
writer.Write((short)pngs.Count);
|
|
|
|
var offset = 6 + 16 * pngs.Count;
|
|
for (var i = 0; i < pngs.Count; i++)
|
|
{
|
|
var dimension = (byte)(sizes[i] >= 256 ? 0 : sizes[i]);
|
|
writer.Write(dimension);
|
|
writer.Write(dimension);
|
|
writer.Write((byte)0);
|
|
writer.Write((byte)0);
|
|
writer.Write((short)1);
|
|
writer.Write((short)32);
|
|
writer.Write(pngs[i].Length);
|
|
writer.Write(offset);
|
|
offset += pngs[i].Length;
|
|
}
|
|
|
|
foreach (var png in pngs)
|
|
writer.Write(png);
|
|
|
|
File.WriteAllBytes(outputPath, memoryStream.ToArray());
|
|
}
|
|
|
|
file sealed record IconTile(RectangleF Bounds, Color Highlight, Color Base);
|
|
|
|
file enum IconStyle
|
|
{
|
|
AxDiamond,
|
|
LegacyGem,
|
|
}
|
|
|
|
file sealed class IconGeneratorOptions
|
|
{
|
|
public string OutputPath { get; init; } = "";
|
|
public string? PreviewPath { get; init; }
|
|
public int PreviewSize { get; init; } = 256;
|
|
public int[] Sizes { get; init; } = [16, 20, 24, 32, 40, 48, 64, 128, 256];
|
|
public IconStyle Style { get; init; } = IconStyle.AxDiamond;
|
|
|
|
public static IconGeneratorOptions Parse(string[] args)
|
|
{
|
|
string? outputPath = null;
|
|
string? previewPath = null;
|
|
var previewSize = 256;
|
|
var style = IconStyle.AxDiamond;
|
|
|
|
foreach (var arg in args)
|
|
{
|
|
if (arg.StartsWith("--preview=", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
previewPath = arg["--preview=".Length..].Trim('"');
|
|
continue;
|
|
}
|
|
|
|
if (arg.StartsWith("--preview-size=", StringComparison.OrdinalIgnoreCase)
|
|
&& int.TryParse(arg["--preview-size=".Length..], out var parsedPreviewSize))
|
|
{
|
|
previewSize = parsedPreviewSize;
|
|
continue;
|
|
}
|
|
|
|
if (arg.StartsWith("--style=", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var styleValue = arg["--style=".Length..].Trim().ToLowerInvariant();
|
|
style = styleValue switch
|
|
{
|
|
"legacy-gem" or "gem" => IconStyle.LegacyGem,
|
|
_ => IconStyle.AxDiamond,
|
|
};
|
|
continue;
|
|
}
|
|
|
|
if (!arg.StartsWith("--", StringComparison.Ordinal))
|
|
outputPath ??= arg.Trim('"');
|
|
}
|
|
|
|
outputPath ??= Path.Combine(AppContext.BaseDirectory, "icon.ico");
|
|
|
|
return new IconGeneratorOptions
|
|
{
|
|
OutputPath = outputPath,
|
|
PreviewPath = previewPath,
|
|
PreviewSize = previewSize,
|
|
Style = style,
|
|
};
|
|
}
|
|
}
|