AX Copilot 아이콘 점유율과 트레이 DPI 프레임을 키운다

작업 표시줄과 트레이에서 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 프레임이 요청 크기 그대로 로드됨
This commit is contained in:
2026-04-15 21:20:55 +09:00
parent 35e0d0dbbf
commit d6a8ab0ddb
5 changed files with 308 additions and 156 deletions

View File

@@ -2,30 +2,115 @@ using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
// 다이아몬드 픽셀 아이콘 생성기 v4
// 보석 다이아몬드 컷 실루엣 (flat top → wide girdle → bottom point)
// 내부 facet 선 + RGBG 4색 채움
// 참고: Samsung Diamond Pixel 구조 (흰색선 다이아몬드 도형)
var options = IconGeneratorOptions.Parse(args);
var pngFrames = new List<byte[]>();
var outputPath = args.Length > 0 ? args[0]
: Path.Combine(AppContext.BaseDirectory, "icon.ico");
int[] sizes = [16, 24, 32, 48, 64, 128, 256];
var pngList = new List<byte[]>();
foreach (var sz in sizes)
foreach (var size in options.Sizes)
{
using var bmp = DrawGemDiamond(sz);
using var ms = new MemoryStream();
bmp.Save(ms, ImageFormat.Png);
pngList.Add(ms.ToArray());
Console.WriteLine($" {sz}x{sz} OK");
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(pngList, sizes, outputPath);
Console.WriteLine($"Icon saved: {outputPath}");
CreateIco(pngFrames, options.Sizes, options.OutputPath);
Console.WriteLine($"Icon saved: {options.OutputPath}");
static Bitmap DrawGemDiamond(int size)
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);
@@ -33,173 +118,211 @@ static Bitmap DrawGemDiamond(int size)
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
g.Clear(Color.Transparent);
float s = size;
float cx = s / 2f;
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;
// ── 보석 다이아몬드 외곽 좌표 ──
// Table (평평한 윗면)
float tableL = s * 0.22f;
float tableR = s * 0.78f;
float tableY = s * 0.18f;
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);
// Girdle (가장 넓은 부분)
float girdleL = s * 0.06f;
float girdleR = s * 0.94f;
float girdleY = s * 0.40f;
// Culet (바닥 뾰족한 점)
float culetX = cx;
float 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); // 바닥 점
// Crown 내부 포인트 (table → girdle 사이 facet)
PointF CM = new(cx, girdleY); // 거들 중앙
PointF CT = new(cx, tableY); // 테이블 중앙
// Crown facet 분할점
PointF CL = new(s * 0.14f, girdleY); // 거들 좌측 근처
PointF CR = new(s * 0.86f, girdleY); // 거들 우측 근처
// Pavilion facet 내부 교차점들
float pavMidY = s * 0.62f;
PointF PL = new(s * 0.28f, pavMidY); // 파빌리온 좌 중간
PointF PR = new(s * 0.72f, pavMidY); // 파빌리온 우 중간
PointF PM = new(cx, pavMidY); // 파빌리온 중앙
// ── 색상 ──
var blue = Color.FromArgb(50, 110, 230);
var blue = Color.FromArgb(50, 110, 230);
var blueBright = Color.FromArgb(80, 150, 255);
var green = Color.FromArgb(60, 200, 80);
var green = Color.FromArgb(60, 200, 80);
var greenBright = Color.FromArgb(100, 235, 110);
var red = Color.FromArgb(230, 50, 65);
var red = Color.FromArgb(230, 50, 65);
var redBright = Color.FromArgb(255, 90, 100);
var greenDk = Color.FromArgb(45, 170, 75);
var greenDk = Color.FromArgb(45, 170, 75);
var greenDkBr = Color.FromArgb(80, 210, 100);
// ── Crown (상단부) 채우기 ──
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);
// Crown 좌: Blue
FillPoly(g, new[]{TL, GL, CM, CT}, blueBright, blue);
// Crown 우: Green
FillPoly(g, new[]{TR, CT, CM, GR}, greenBright, green);
// ── Pavilion (하단부) 채우기 ──
// Pavilion 좌: Red
FillPoly(g, new[]{GL, CM, BT}, redBright, red);
// Pavilion 우: Green (darker)
FillPoly(g, new[]{CM, GR, BT}, greenDkBr, greenDk);
// ── Facet 내부 색상 변화 (깊이감) ──
// Crown 좌측 어두운 삼각형
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)});
g.FillPolygon(crownShadow, new[] { tl, gl, new PointF(cx * 0.7f, girdleY * 0.85f) });
// Pavilion 중앙 밝은 삼각형 (반사)
using var pavHighlight = new SolidBrush(Color.FromArgb(20, 255, 255, 255));
g.FillPolygon(pavHighlight, new[]{CM, BT, new PointF(cx - s*0.08f, pavMidY)});
g.FillPolygon(pavHighlight, new[] { cm, bt, new PointF(cx - s * 0.08f, s * 0.62f) });
// ── Facet 선 (흰색) ──
float lw = Math.Max(0.8f, s / 140f);
using var facetPen = new Pen(Color.FromArgb(180, 255, 255, 255), lw)
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
};
// Crown facet lines
// 테이블 윗변
g.DrawLine(facetPen, TL, TR);
// 테이블 → 거들 대각선
g.DrawLine(facetPen, TL, GL);
g.DrawLine(facetPen, TR, GR);
// 세로 중심선 (table → girdle)
g.DrawLine(facetPen, CT, CM);
// Crown 크로스 facet
g.DrawLine(facetPen, TL, CM);
g.DrawLine(facetPen, TR, CM);
// 추가 Crown facet (table 모서리 → 거들 중간)
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, 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);
// Girdle 수평선
g.DrawLine(facetPen, GL, GR);
// Pavilion facet lines
// 거들 → 바닥 점
g.DrawLine(facetPen, GL, BT);
g.DrawLine(facetPen, GR, BT);
// 중심 → 바닥
g.DrawLine(facetPen, CM, BT);
// Pavilion 크로스 facets
float crossY = girdleY + (culetY - girdleY) * 0.45f;
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);
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);
// ── 외곽선 (두꺼운 흰색) ──
float olw = Math.Max(1.2f, s / 100f);
using var outlinePen = new Pen(Color.FromArgb(220, 255, 255, 255), olw)
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});
g.DrawPolygon(outlinePen, new[] { tl, tr, gr, bt, gl });
// ── 상단 하이라이트 (테이블 면 빛 반사) ──
using var tableHL = new LinearGradientBrush(
TL, new PointF(cx, girdleY),
using var tableHighlight = new LinearGradientBrush(
tl,
new PointF(cx, girdleY),
Color.FromArgb(45, 255, 255, 255),
Color.Transparent);
g.FillPolygon(tableHL, new[]{TL, TR, CT});
g.FillPolygon(tableHighlight, new[] { tl, tr, ct });
return bmp;
}
static void FillPoly(Graphics g, PointF[] pts, Color c1, Color c2)
static GraphicsPath CreateRoundedRectangle(RectangleF bounds, float radius)
{
float minX = pts.Min(p => p.X), maxX = pts.Max(p => p.X);
float minY = pts.Min(p => p.Y), maxY = pts.Max(p => p.Y);
var rect = new RectangleF(minX, minY, Math.Max(1, maxX - minX), Math.Max(1, maxY - minY));
try
var path = new GraphicsPath();
var diameter = radius * 2f;
if (radius <= 0.01f)
{
using var brush = new LinearGradientBrush(rect, c1, c2, LinearGradientMode.Vertical);
g.FillPolygon(brush, pts);
}
catch
{
using var brush = new SolidBrush(c1);
g.FillPolygon(brush, pts);
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 CreateIco(List<byte[]> pngs, int[] sizes, string path)
static void FillPolygon(Graphics g, PointF[] points, Color topColor, Color bottomColor)
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write((short)0);
bw.Write((short)1);
bw.Write((short)pngs.Count);
int offset = 6 + 16 * pngs.Count;
for (int i = 0; i < pngs.Count; i++)
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++)
{
byte dim = (byte)(sizes[i] >= 256 ? 0 : sizes[i]);
bw.Write(dim); bw.Write(dim);
bw.Write((byte)0); bw.Write((byte)0);
bw.Write((short)1); bw.Write((short)32);
bw.Write(pngs[i].Length); bw.Write(offset);
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) bw.Write(png);
File.WriteAllBytes(path, ms.ToArray());
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,
};
}
}