finally
This commit is contained in:
@@ -355,11 +355,11 @@ Same caveat about duplicate entries applies.
|
|||||||
### 5.9 GIF Export
|
### 5.9 GIF Export
|
||||||
|
|
||||||
1. For each document in container, composite into RGBA buffer.
|
1. For each document in container, composite into RGBA buffer.
|
||||||
2. Use external library (e.g. `SixLabors.ImageSharp`) to encode frames
|
2. Quantize each frame to 256 colors (popularity-based, with transparent slot).
|
||||||
with per-frame delay from `FrameDelayMs`.
|
3. LZW-compress and write as GIF89a with per-frame delay from `FrameDelayMs`.
|
||||||
3. Output as animated GIF.
|
4. NETSCAPE2.0 application extension for infinite looping.
|
||||||
|
|
||||||
Only the GIF encoder is external; the `.minint` format is fully self-implemented.
|
Fully self-implemented — no external library for GIF encoding.
|
||||||
|
|
||||||
### 5.10 Pattern Generation (Б4)
|
### 5.10 Pattern Generation (Б4)
|
||||||
|
|
||||||
@@ -438,11 +438,11 @@ Canvas (`PixelCanvas` custom control):
|
|||||||
**Chosen**: eraser writes index 0 (transparent).
|
**Chosen**: eraser writes index 0 (transparent).
|
||||||
**Rationale**: consistent with palette convention; compositing naturally handles it.
|
**Rationale**: consistent with palette convention; compositing naturally handles it.
|
||||||
|
|
||||||
### D7. GIF export library
|
### D7. GIF export
|
||||||
|
|
||||||
**Chosen**: external library for GIF encoding only (`SixLabors.ImageSharp` or equivalent).
|
**Chosen**: fully self-implemented GIF89a encoder with LZW compression.
|
||||||
**Rationale**: GIF LZW compression is complex and not the focus of this project.
|
**Rationale**: avoids external dependencies; the entire project uses only Avalonia and CommunityToolkit.Mvvm.
|
||||||
**Constraint**: `.minint` serialization remains fully self-implemented.
|
**Details**: popularity-based color quantization to 256 colors, NETSCAPE2.0 looping extension.
|
||||||
|
|
||||||
### D8. No undo/redo in initial implementation
|
### D8. No undo/redo in initial implementation
|
||||||
|
|
||||||
@@ -456,16 +456,16 @@ Canvas (`PixelCanvas` custom control):
|
|||||||
| Stage | Scope |
|
| Stage | Scope |
|
||||||
|-------|----------------------------------------------------|
|
|-------|----------------------------------------------------|
|
||||||
| 1 | ✅ Architecture & design (this document) |
|
| 1 | ✅ Architecture & design (this document) |
|
||||||
| 2 | Solution scaffold + domain models |
|
| 2 | ✅ Solution scaffold + domain models |
|
||||||
| 3 | Binary `.minint` serialization + round-trip test |
|
| 3 | ✅ Binary `.minint` serialization + round-trip test |
|
||||||
| 4 | Compositing + palette management + RGBA buffer |
|
| 4 | ✅ Compositing + palette management + RGBA buffer |
|
||||||
| 5 | Basic Avalonia UI (main window, menus, panels) |
|
| 5 | ✅ Basic Avalonia UI (main window, menus, panels) |
|
||||||
| 6 | Canvas: pan, zoom, grid, nearest-neighbor |
|
| 6 | ✅ Canvas: pan, zoom, grid, nearest-neighbor |
|
||||||
| 7 | Drawing tools: brush, eraser, flood fill, preview |
|
| 7 | ✅ Drawing tools: brush, eraser, flood fill, preview |
|
||||||
| 8 | Layer & document management UI |
|
| 8 | ✅ Layer & document management UI |
|
||||||
| 9 | Effects: contrast, grayscale, fragment copy, patterns |
|
| 9 | ✅ Effects: contrast, grayscale, fragment copy, patterns |
|
||||||
| 10 | Animation playback + BMP/GIF export |
|
| 10 | ✅ Animation playback + BMP/GIF export |
|
||||||
| 11 | Polish, tests, documentation |
|
| 11 | ✅ Polish, tests, documentation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -477,6 +477,6 @@ Canvas (`PixelCanvas` custom control):
|
|||||||
| Avalonia.Desktop 11.3.8 | Minint (UI) | Desktop host |
|
| Avalonia.Desktop 11.3.8 | Minint (UI) | Desktop host |
|
||||||
| Avalonia.Themes.Fluent 11.3.8 | Minint (UI) | Theme |
|
| Avalonia.Themes.Fluent 11.3.8 | Minint (UI) | Theme |
|
||||||
| CommunityToolkit.Mvvm 8.2.1 | Minint (UI) | MVVM helpers |
|
| CommunityToolkit.Mvvm 8.2.1 | Minint (UI) | MVVM helpers |
|
||||||
| SixLabors.ImageSharp (TBD) | Infrastructure | GIF export only |
|
| _(no external deps)_ | Infrastructure | GIF/BMP fully self-implemented |
|
||||||
|
|
||||||
Core project: **zero** external dependencies.
|
Core project: **zero** external dependencies.
|
||||||
|
|||||||
122
FORMAT.md
Normal file
122
FORMAT.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Спецификация формата `.minint` (версия 1)
|
||||||
|
|
||||||
|
## Общие сведения
|
||||||
|
|
||||||
|
- Все многобайтовые целые числа — **little-endian**.
|
||||||
|
- Строки — **UTF-8**, с префиксом длины 1 байт (макс. 255 байт).
|
||||||
|
- Формат не использует сжатие — данные хранятся как есть.
|
||||||
|
|
||||||
|
## Структура файла
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ Container Header │ 28 байт (фиксированный)
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ Document 1 │ (блок переменной длины)
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ Document 2 │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ ... │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ Document N │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container Header (28 байт)
|
||||||
|
|
||||||
|
| Смещение | Размер | Тип | Описание |
|
||||||
|
|----------|--------|--------|---------------------------------|
|
||||||
|
| 0 | 6 | ASCII | Сигнатура: `MININT` |
|
||||||
|
| 6 | 2 | uint16 | Версия формата (текущая: `1`) |
|
||||||
|
| 8 | 4 | uint32 | Ширина (Width) |
|
||||||
|
| 12 | 4 | uint32 | Высота (Height) |
|
||||||
|
| 16 | 4 | uint32 | Количество документов |
|
||||||
|
| 20 | 8 | — | Зарезервировано (нули) |
|
||||||
|
|
||||||
|
## Блок документа
|
||||||
|
|
||||||
|
Повторяется `DocumentCount` раз, последовательно.
|
||||||
|
|
||||||
|
### Заголовок документа
|
||||||
|
|
||||||
|
| Размер | Тип | Описание |
|
||||||
|
|---------------------|--------|-----------------------------|
|
||||||
|
| 1 | uint8 | Длина имени (NameLen) |
|
||||||
|
| NameLen | UTF-8 | Имя документа |
|
||||||
|
| 4 | uint32 | FrameDelayMs |
|
||||||
|
| 4 | uint32 | Количество цветов (PalCnt) |
|
||||||
|
|
||||||
|
### Палитра
|
||||||
|
|
||||||
|
`PalCnt × 4` байт. Каждый цвет: `[R, G, B, A]` (по 1 байту).
|
||||||
|
|
||||||
|
Индекс 0 всегда соответствует прозрачному цвету `(0, 0, 0, 0)`.
|
||||||
|
|
||||||
|
### Ширина индекса
|
||||||
|
|
||||||
|
Вычисляется из `PalCnt` (не хранится в файле явно):
|
||||||
|
|
||||||
|
| PalCnt | Байт на индекс |
|
||||||
|
|---------------------|-----------------|
|
||||||
|
| 1 – 255 | 1 |
|
||||||
|
| 256 – 65 535 | 2 |
|
||||||
|
| 65 536 – 16 777 215 | 3 |
|
||||||
|
| 16 777 216+ | 4 |
|
||||||
|
|
||||||
|
### Количество слоёв
|
||||||
|
|
||||||
|
| Размер | Тип | Описание |
|
||||||
|
|--------|--------|-------------------|
|
||||||
|
| 4 | uint32 | Количество слоёв |
|
||||||
|
|
||||||
|
### Блок слоя
|
||||||
|
|
||||||
|
Повторяется `LayerCount` раз.
|
||||||
|
|
||||||
|
| Размер | Тип | Описание |
|
||||||
|
|---------------------------|-------|------------------------------------|
|
||||||
|
| 1 | uint8 | Длина имени слоя (LayerNameLen) |
|
||||||
|
| LayerNameLen | UTF-8 | Имя слоя |
|
||||||
|
| 1 | uint8 | Видимость (0 = скрыт, 1 = виден) |
|
||||||
|
| 1 | uint8 | Opacity (0–255) |
|
||||||
|
| Width × Height × ByteWidth | bytes | Индексы палитры, row-major, LE |
|
||||||
|
|
||||||
|
## Правила валидации
|
||||||
|
|
||||||
|
1. Сигнатура — строго `MININT` (6 байт ASCII).
|
||||||
|
2. Версия — строго `1` (неизвестные версии отклоняются).
|
||||||
|
3. Width, Height >= 1; максимум 65 536.
|
||||||
|
4. DocumentCount >= 1.
|
||||||
|
5. PaletteCount >= 1.
|
||||||
|
6. Каждый индекс пикселя < PaletteCount.
|
||||||
|
7. IsVisible — только 0 или 1.
|
||||||
|
8. Зарезервированные байты — допускаются ненулевые (forward compatibility).
|
||||||
|
9. Неожиданный конец файла — ошибка с описанием контекста.
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
|
Контейнер 4×4, 1 документ, 2 цвета (прозрачный + красный), 1 слой:
|
||||||
|
|
||||||
|
```
|
||||||
|
4D 49 4E 49 4E 54 — "MININT"
|
||||||
|
01 00 — version 1
|
||||||
|
04 00 00 00 — width = 4
|
||||||
|
04 00 00 00 — height = 4
|
||||||
|
01 00 00 00 — 1 document
|
||||||
|
00 00 00 00 00 00 00 00 — reserved
|
||||||
|
|
||||||
|
05 — name length = 5
|
||||||
|
44 6F 63 20 31 — "Doc 1"
|
||||||
|
64 00 00 00 — FrameDelayMs = 100
|
||||||
|
02 00 00 00 — palette count = 2
|
||||||
|
00 00 00 00 — color 0: transparent
|
||||||
|
FF 00 00 FF — color 1: red (R=255, G=0, B=0, A=255)
|
||||||
|
|
||||||
|
01 00 00 00 — 1 layer
|
||||||
|
07 — layer name length = 7
|
||||||
|
4C 61 79 65 72 20 31 — "Layer 1"
|
||||||
|
01 — visible
|
||||||
|
FF — opacity = 255
|
||||||
|
00 00 00 00 00 00 00 00 — 16 pixels, all index 0 (transparent)
|
||||||
|
00 00 00 00 00 00 00 00
|
||||||
|
```
|
||||||
76
Minint.Tests/CompositorTests.cs
Normal file
76
Minint.Tests/CompositorTests.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class CompositorTests
|
||||||
|
{
|
||||||
|
private readonly Compositor _compositor = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Composite_EmptyLayer_AllTransparent()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
doc.Layers.Add(new MinintLayer("L1", 4));
|
||||||
|
|
||||||
|
uint[] result = _compositor.Composite(doc, 2, 2);
|
||||||
|
|
||||||
|
Assert.All(result, px => Assert.Equal(0u, px));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Composite_SingleOpaquePixel()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
var red = new RgbaColor(255, 0, 0, 255);
|
||||||
|
doc.EnsureColorCached(red);
|
||||||
|
var layer = new MinintLayer("L1", 4);
|
||||||
|
layer.Pixels[0] = 1;
|
||||||
|
doc.Layers.Add(layer);
|
||||||
|
|
||||||
|
uint[] result = _compositor.Composite(doc, 2, 2);
|
||||||
|
|
||||||
|
// ARGB packed as 0xAARRGGBB
|
||||||
|
uint expected = 0xFF_FF_00_00u;
|
||||||
|
Assert.Equal(expected, result[0]);
|
||||||
|
Assert.Equal(0u, result[1]); // rest is transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Composite_HiddenLayer_Ignored()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
doc.EnsureColorCached(new RgbaColor(0, 255, 0, 255));
|
||||||
|
var layer = new MinintLayer("L1", 4);
|
||||||
|
layer.Pixels[0] = 1;
|
||||||
|
layer.IsVisible = false;
|
||||||
|
doc.Layers.Add(layer);
|
||||||
|
|
||||||
|
uint[] result = _compositor.Composite(doc, 2, 2);
|
||||||
|
|
||||||
|
Assert.Equal(0u, result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Composite_TwoLayers_TopOverBottom()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
var red = new RgbaColor(255, 0, 0, 255);
|
||||||
|
var blue = new RgbaColor(0, 0, 255, 255);
|
||||||
|
int redIdx = doc.EnsureColorCached(red);
|
||||||
|
int blueIdx = doc.EnsureColorCached(blue);
|
||||||
|
|
||||||
|
var bottom = new MinintLayer("bottom", 1);
|
||||||
|
bottom.Pixels[0] = redIdx;
|
||||||
|
var top = new MinintLayer("top", 1);
|
||||||
|
top.Pixels[0] = blueIdx;
|
||||||
|
|
||||||
|
doc.Layers.Add(bottom);
|
||||||
|
doc.Layers.Add(top);
|
||||||
|
|
||||||
|
uint[] result = _compositor.Composite(doc, 1, 1);
|
||||||
|
|
||||||
|
// Blue on top, fully opaque, should overwrite red
|
||||||
|
Assert.Equal(0xFF_00_00_FFu, result[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Minint.Tests/DrawingTests.cs
Normal file
63
Minint.Tests/DrawingTests.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class DrawingTests
|
||||||
|
{
|
||||||
|
private readonly DrawingService _drawing = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyBrush_Radius0_SetsSinglePixel()
|
||||||
|
{
|
||||||
|
var layer = new MinintLayer("L1", 9);
|
||||||
|
_drawing.ApplyBrush(layer, 1, 1, 0, 1, 3, 3);
|
||||||
|
|
||||||
|
Assert.Equal(1, layer.Pixels[1 * 3 + 1]);
|
||||||
|
Assert.Equal(0, layer.Pixels[0]); // (0,0) untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyBrush_Radius1_SetsCircle()
|
||||||
|
{
|
||||||
|
var layer = new MinintLayer("L1", 25);
|
||||||
|
_drawing.ApplyBrush(layer, 2, 2, 1, 1, 5, 5);
|
||||||
|
|
||||||
|
// Center + 4 neighbors should be set
|
||||||
|
Assert.Equal(1, layer.Pixels[2 * 5 + 2]); // center
|
||||||
|
Assert.Equal(1, layer.Pixels[1 * 5 + 2]); // top
|
||||||
|
Assert.Equal(1, layer.Pixels[3 * 5 + 2]); // bottom
|
||||||
|
Assert.Equal(1, layer.Pixels[2 * 5 + 1]); // left
|
||||||
|
Assert.Equal(1, layer.Pixels[2 * 5 + 3]); // right
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyEraser_SetsToZero()
|
||||||
|
{
|
||||||
|
var layer = new MinintLayer("L1", 9);
|
||||||
|
Array.Fill(layer.Pixels, 5);
|
||||||
|
_drawing.ApplyEraser(layer, 1, 1, 0, 3, 3);
|
||||||
|
|
||||||
|
Assert.Equal(0, layer.Pixels[1 * 3 + 1]);
|
||||||
|
Assert.Equal(5, layer.Pixels[0]); // untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetBrushMask_Radius0_SinglePixel()
|
||||||
|
{
|
||||||
|
var mask = _drawing.GetBrushMask(2, 2, 0, 5, 5);
|
||||||
|
Assert.Single(mask);
|
||||||
|
Assert.Equal((2, 2), mask[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetBrushMask_OutOfBounds_Clamped()
|
||||||
|
{
|
||||||
|
var mask = _drawing.GetBrushMask(0, 0, 2, 3, 3);
|
||||||
|
Assert.All(mask, p =>
|
||||||
|
{
|
||||||
|
Assert.InRange(p.X, 0, 2);
|
||||||
|
Assert.InRange(p.Y, 0, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Minint.Tests/ExportTests.cs
Normal file
68
Minint.Tests/ExportTests.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
using Minint.Infrastructure.Export;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class ExportTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BmpExport_WritesValidBmp()
|
||||||
|
{
|
||||||
|
var exporter = new BmpExporter();
|
||||||
|
var pixels = new uint[] { 0xFF_FF_00_00, 0xFF_00_FF_00, 0xFF_00_00_FF, 0xFF_FF_FF_FF };
|
||||||
|
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
exporter.Export(ms, pixels, 2, 2);
|
||||||
|
|
||||||
|
ms.Position = 0;
|
||||||
|
byte[] data = ms.ToArray();
|
||||||
|
|
||||||
|
Assert.True(data.Length > 0);
|
||||||
|
Assert.Equal((byte)'B', data[0]);
|
||||||
|
Assert.Equal((byte)'M', data[1]);
|
||||||
|
|
||||||
|
int fileSize = BitConverter.ToInt32(data, 2);
|
||||||
|
Assert.Equal(data.Length, fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GifExport_WritesValidGif()
|
||||||
|
{
|
||||||
|
var exporter = new GifExporter();
|
||||||
|
var frame1 = new uint[16]; // 4x4 transparent
|
||||||
|
var frame2 = new uint[16];
|
||||||
|
Array.Fill(frame2, 0xFF_FF_00_00u);
|
||||||
|
|
||||||
|
var frames = new List<(uint[] Pixels, uint DelayMs)>
|
||||||
|
{
|
||||||
|
(frame1, 100),
|
||||||
|
(frame2, 200),
|
||||||
|
};
|
||||||
|
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
exporter.Export(ms, frames, 4, 4);
|
||||||
|
|
||||||
|
ms.Position = 0;
|
||||||
|
byte[] data = ms.ToArray();
|
||||||
|
|
||||||
|
Assert.True(data.Length > 0);
|
||||||
|
string sig = Encoding.ASCII.GetString(data, 0, 6);
|
||||||
|
Assert.Equal("GIF89a", sig);
|
||||||
|
Assert.Equal(0x3B, data[^1]); // GIF trailer
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BmpExport_DimensionMismatch_Throws()
|
||||||
|
{
|
||||||
|
var exporter = new BmpExporter();
|
||||||
|
var pixels = new uint[3]; // does not match 2x2
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(() =>
|
||||||
|
{
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
exporter.Export(ms, pixels, 2, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Minint.Tests/FloodFillTests.cs
Normal file
52
Minint.Tests/FloodFillTests.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class FloodFillTests
|
||||||
|
{
|
||||||
|
private readonly FloodFillService _fill = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fill_EmptyLayer_FillsAll()
|
||||||
|
{
|
||||||
|
var layer = new MinintLayer("L1", 9); // 3x3, all zeros
|
||||||
|
_fill.Fill(layer, 0, 0, 1, 3, 3);
|
||||||
|
|
||||||
|
Assert.All(layer.Pixels, px => Assert.Equal(1, px));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fill_SameColor_NoOp()
|
||||||
|
{
|
||||||
|
var layer = new MinintLayer("L1", 4);
|
||||||
|
Array.Fill(layer.Pixels, 2);
|
||||||
|
_fill.Fill(layer, 0, 0, 2, 2, 2);
|
||||||
|
|
||||||
|
Assert.All(layer.Pixels, px => Assert.Equal(2, px));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fill_Bounded_DoesNotCrossBorder()
|
||||||
|
{
|
||||||
|
// 3x3 grid with a wall:
|
||||||
|
// 0 0 0
|
||||||
|
// 1 1 1
|
||||||
|
// 0 0 0
|
||||||
|
var layer = new MinintLayer("L1", 9);
|
||||||
|
layer.Pixels[3] = 1; // (0,1)
|
||||||
|
layer.Pixels[4] = 1; // (1,1)
|
||||||
|
layer.Pixels[5] = 1; // (2,1)
|
||||||
|
|
||||||
|
_fill.Fill(layer, 0, 0, 2, 3, 3);
|
||||||
|
|
||||||
|
// Top row should be filled
|
||||||
|
Assert.Equal(2, layer.Pixels[0]);
|
||||||
|
Assert.Equal(2, layer.Pixels[1]);
|
||||||
|
Assert.Equal(2, layer.Pixels[2]);
|
||||||
|
// Wall untouched
|
||||||
|
Assert.Equal(1, layer.Pixels[3]);
|
||||||
|
// Bottom row untouched (blocked by wall)
|
||||||
|
Assert.Equal(0, layer.Pixels[6]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Minint.Tests/FragmentServiceTests.cs
Normal file
68
Minint.Tests/FragmentServiceTests.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class FragmentServiceTests
|
||||||
|
{
|
||||||
|
private readonly FragmentService _fragment = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CopyFragment_SameDocument_CopiesPixels()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
var red = new RgbaColor(255, 0, 0, 255);
|
||||||
|
doc.EnsureColorCached(red);
|
||||||
|
|
||||||
|
var src = new MinintLayer("src", 16);
|
||||||
|
src.Pixels[0] = 1; // (0,0) = red
|
||||||
|
src.Pixels[1] = 1; // (1,0) = red
|
||||||
|
doc.Layers.Add(src);
|
||||||
|
|
||||||
|
var dst = new MinintLayer("dst", 16);
|
||||||
|
doc.Layers.Add(dst);
|
||||||
|
|
||||||
|
_fragment.CopyFragment(doc, 0, 0, 0, 2, 1, doc, 1, 2, 2, 4, 4);
|
||||||
|
|
||||||
|
Assert.Equal(1, dst.Pixels[2 * 4 + 2]); // (2,2)
|
||||||
|
Assert.Equal(1, dst.Pixels[2 * 4 + 3]); // (3,2)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CopyFragment_DifferentDocuments_MergesPalette()
|
||||||
|
{
|
||||||
|
var srcDoc = new MinintDocument("src");
|
||||||
|
var blue = new RgbaColor(0, 0, 255, 255);
|
||||||
|
int blueIdx = srcDoc.EnsureColorCached(blue);
|
||||||
|
var srcLayer = new MinintLayer("L1", 4);
|
||||||
|
srcLayer.Pixels[0] = blueIdx;
|
||||||
|
srcDoc.Layers.Add(srcLayer);
|
||||||
|
|
||||||
|
var dstDoc = new MinintDocument("dst");
|
||||||
|
var dstLayer = new MinintLayer("L1", 4);
|
||||||
|
dstDoc.Layers.Add(dstLayer);
|
||||||
|
|
||||||
|
_fragment.CopyFragment(srcDoc, 0, 0, 0, 1, 1, dstDoc, 0, 0, 0, 2, 2);
|
||||||
|
|
||||||
|
int dstBlueIdx = dstDoc.FindColorCached(blue);
|
||||||
|
Assert.True(dstBlueIdx > 0);
|
||||||
|
Assert.Equal(dstBlueIdx, dstLayer.Pixels[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CopyFragment_TransparentPixels_Skipped()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
var src = new MinintLayer("src", 4); // all zeros (transparent)
|
||||||
|
doc.Layers.Add(src);
|
||||||
|
|
||||||
|
var dst = new MinintLayer("dst", 4);
|
||||||
|
Array.Fill(dst.Pixels, 0);
|
||||||
|
dst.Pixels[0] = 0; // explicitly 0
|
||||||
|
doc.Layers.Add(dst);
|
||||||
|
|
||||||
|
_fragment.CopyFragment(doc, 0, 0, 0, 2, 2, doc, 1, 0, 0, 2, 2);
|
||||||
|
|
||||||
|
Assert.Equal(0, dst.Pixels[0]); // stays transparent
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Minint.Tests/ImageEffectsTests.cs
Normal file
65
Minint.Tests/ImageEffectsTests.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class ImageEffectsTests
|
||||||
|
{
|
||||||
|
private readonly ImageEffectsService _effects = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyGrayscale_ConvertsColors()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
var red = new RgbaColor(255, 0, 0, 255);
|
||||||
|
doc.EnsureColorCached(red);
|
||||||
|
|
||||||
|
_effects.ApplyGrayscale(doc);
|
||||||
|
|
||||||
|
var gray = doc.Palette[1];
|
||||||
|
Assert.Equal(gray.R, gray.G);
|
||||||
|
Assert.Equal(gray.G, gray.B);
|
||||||
|
Assert.Equal(255, gray.A);
|
||||||
|
// BT.601: 0.299*255 ≈ 76
|
||||||
|
Assert.InRange(gray.R, 74, 78);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyGrayscale_PreservesTransparentIndex()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
doc.EnsureColorCached(new RgbaColor(100, 200, 50, 255));
|
||||||
|
|
||||||
|
_effects.ApplyGrayscale(doc);
|
||||||
|
|
||||||
|
Assert.Equal(RgbaColor.Transparent, doc.Palette[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyContrast_IncreasesContrast()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
var midGray = new RgbaColor(128, 128, 128, 255);
|
||||||
|
var lightGray = new RgbaColor(192, 192, 192, 255);
|
||||||
|
doc.EnsureColorCached(midGray);
|
||||||
|
doc.EnsureColorCached(lightGray);
|
||||||
|
|
||||||
|
_effects.ApplyContrast(doc, 2.0);
|
||||||
|
|
||||||
|
// midGray (128) stays ~128: factor*(128-128)+128 = 128
|
||||||
|
Assert.InRange(doc.Palette[1].R, 126, 130);
|
||||||
|
// lightGray (192): factor*(192-128)+128 = 2*64+128 = 256 → clamped to 255
|
||||||
|
Assert.Equal(255, doc.Palette[2].R);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyContrast_PreservesAlpha()
|
||||||
|
{
|
||||||
|
var doc = new MinintDocument("test");
|
||||||
|
doc.EnsureColorCached(new RgbaColor(100, 100, 100, 200));
|
||||||
|
|
||||||
|
_effects.ApplyContrast(doc, 1.5);
|
||||||
|
|
||||||
|
Assert.Equal(200, doc.Palette[1].A);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Minint.Tests/Minint.Tests.csproj
Normal file
26
Minint.Tests/Minint.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Minint.Core\Minint.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
41
Minint.Tests/PatternGeneratorTests.cs
Normal file
41
Minint.Tests/PatternGeneratorTests.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class PatternGeneratorTests
|
||||||
|
{
|
||||||
|
private readonly PatternGenerator _gen = new();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PatternType.Checkerboard)]
|
||||||
|
[InlineData(PatternType.HorizontalGradient)]
|
||||||
|
[InlineData(PatternType.VerticalGradient)]
|
||||||
|
[InlineData(PatternType.HorizontalStripes)]
|
||||||
|
[InlineData(PatternType.VerticalStripes)]
|
||||||
|
[InlineData(PatternType.ConcentricCircles)]
|
||||||
|
[InlineData(PatternType.Tile)]
|
||||||
|
public void Generate_AllTypes_ProducesValidDocument(PatternType type)
|
||||||
|
{
|
||||||
|
var colors = new[] { new RgbaColor(255, 0, 0, 255), new RgbaColor(0, 0, 255, 255) };
|
||||||
|
var doc = _gen.Generate(type, 16, 16, colors, 4, 4);
|
||||||
|
|
||||||
|
Assert.Equal($"Pattern ({type})", doc.Name);
|
||||||
|
Assert.Single(doc.Layers);
|
||||||
|
Assert.Equal(256, doc.Layers[0].Pixels.Length);
|
||||||
|
Assert.True(doc.Palette.Count >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_Checkerboard_AlternatesColors()
|
||||||
|
{
|
||||||
|
var colors = new[] { new RgbaColor(255, 0, 0, 255), new RgbaColor(0, 255, 0, 255) };
|
||||||
|
var doc = _gen.Generate(PatternType.Checkerboard, 4, 4, colors, 2);
|
||||||
|
|
||||||
|
var layer = doc.Layers[0];
|
||||||
|
int topLeft = layer.Pixels[0];
|
||||||
|
int topRight = layer.Pixels[2]; // cellSize=2, so (2,0) is next cell
|
||||||
|
Assert.NotEqual(topLeft, topRight);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
Minint.Tests/SerializerTests.cs
Normal file
121
Minint.Tests/SerializerTests.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Infrastructure.Serialization;
|
||||||
|
|
||||||
|
namespace Minint.Tests;
|
||||||
|
|
||||||
|
public class SerializerTests
|
||||||
|
{
|
||||||
|
private readonly MinintSerializer _serializer = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_EmptyDocument_PreservesStructure()
|
||||||
|
{
|
||||||
|
var container = new MinintContainer(32, 16);
|
||||||
|
container.AddNewDocument("Doc1");
|
||||||
|
|
||||||
|
var result = RoundTrip(container);
|
||||||
|
|
||||||
|
Assert.Equal(32, result.Width);
|
||||||
|
Assert.Equal(16, result.Height);
|
||||||
|
Assert.Single(result.Documents);
|
||||||
|
Assert.Equal("Doc1", result.Documents[0].Name);
|
||||||
|
Assert.Single(result.Documents[0].Layers);
|
||||||
|
Assert.Equal(32 * 16, result.Documents[0].Layers[0].Pixels.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_MultipleDocuments_PreservesAll()
|
||||||
|
{
|
||||||
|
var container = new MinintContainer(8, 8);
|
||||||
|
var doc1 = container.AddNewDocument("Frame1");
|
||||||
|
doc1.FrameDelayMs = 200;
|
||||||
|
var doc2 = container.AddNewDocument("Frame2");
|
||||||
|
doc2.FrameDelayMs = 500;
|
||||||
|
|
||||||
|
var result = RoundTrip(container);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Documents.Count);
|
||||||
|
Assert.Equal("Frame1", result.Documents[0].Name);
|
||||||
|
Assert.Equal(200u, result.Documents[0].FrameDelayMs);
|
||||||
|
Assert.Equal("Frame2", result.Documents[1].Name);
|
||||||
|
Assert.Equal(500u, result.Documents[1].FrameDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_PaletteAndPixels_Preserved()
|
||||||
|
{
|
||||||
|
var container = new MinintContainer(4, 4);
|
||||||
|
var doc = container.AddNewDocument("Test");
|
||||||
|
var red = new RgbaColor(255, 0, 0, 255);
|
||||||
|
doc.EnsureColorCached(red);
|
||||||
|
|
||||||
|
var layer = doc.Layers[0];
|
||||||
|
layer.Pixels[0] = 1; // red
|
||||||
|
|
||||||
|
var result = RoundTrip(container);
|
||||||
|
var rdoc = result.Documents[0];
|
||||||
|
|
||||||
|
Assert.Equal(2, rdoc.Palette.Count); // transparent + red
|
||||||
|
Assert.Equal(RgbaColor.Transparent, rdoc.Palette[0]);
|
||||||
|
Assert.Equal(red, rdoc.Palette[1]);
|
||||||
|
Assert.Equal(1, rdoc.Layers[0].Pixels[0]);
|
||||||
|
Assert.Equal(0, rdoc.Layers[0].Pixels[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_LayerProperties_Preserved()
|
||||||
|
{
|
||||||
|
var container = new MinintContainer(2, 2);
|
||||||
|
var doc = container.AddNewDocument("Test");
|
||||||
|
doc.Layers[0].Name = "Background";
|
||||||
|
doc.Layers[0].IsVisible = false;
|
||||||
|
doc.Layers[0].Opacity = 128;
|
||||||
|
|
||||||
|
var result = RoundTrip(container);
|
||||||
|
var layer = result.Documents[0].Layers[0];
|
||||||
|
|
||||||
|
Assert.Equal("Background", layer.Name);
|
||||||
|
Assert.False(layer.IsVisible);
|
||||||
|
Assert.Equal(128, layer.Opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_LargePalette_Uses2ByteIndices()
|
||||||
|
{
|
||||||
|
var container = new MinintContainer(2, 2);
|
||||||
|
var doc = container.AddNewDocument("BigPalette");
|
||||||
|
|
||||||
|
for (int i = 0; i < 300; i++)
|
||||||
|
doc.EnsureColorCached(new RgbaColor((byte)(i % 256), (byte)(i / 256), 0, 255));
|
||||||
|
|
||||||
|
Assert.Equal(2, doc.IndexByteWidth);
|
||||||
|
|
||||||
|
int lastIdx = doc.Palette.Count - 1;
|
||||||
|
doc.Layers[0].Pixels[0] = lastIdx;
|
||||||
|
|
||||||
|
var result = RoundTrip(container);
|
||||||
|
Assert.Equal(lastIdx, result.Documents[0].Layers[0].Pixels[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_InvalidSignature_Throws()
|
||||||
|
{
|
||||||
|
var ms = new MemoryStream("BADDATA"u8.ToArray());
|
||||||
|
Assert.Throws<InvalidDataException>(() => _serializer.Read(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_TruncatedStream_Throws()
|
||||||
|
{
|
||||||
|
var ms = new MemoryStream("MINI"u8.ToArray());
|
||||||
|
Assert.Throws<InvalidDataException>(() => _serializer.Read(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MinintContainer RoundTrip(MinintContainer container)
|
||||||
|
{
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
_serializer.Write(ms, container);
|
||||||
|
ms.Position = 0;
|
||||||
|
return _serializer.Read(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="Minint.Core/Minint.Core.csproj" />
|
<Project Path="Minint.Core/Minint.Core.csproj" />
|
||||||
<Project Path="Minint.Infrastructure/Minint.Infrastructure.csproj" />
|
<Project Path="Minint.Infrastructure/Minint.Infrastructure.csproj" />
|
||||||
|
<Project Path="Minint.Tests/Minint.Tests.csproj" />
|
||||||
<Project Path="Minint/Minint.csproj" />
|
<Project Path="Minint/Minint.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Minint
|
||||||
|
|
||||||
|
> Растровый редактор с собственным бинарным форматом `.minint`, поддержкой слоёв, анимации и палитровых эффектов.
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Desktop-приложение для создания и редактирования пиксельной графики. Изображения хранятся в собственном бинарном формате с палитровой моделью, где каждый документ (кадр) содержит свою палитру RGBA и набор слоёв. Контейнер объединяет несколько документов для анимации.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Собственный формат `.minint`** — самописная бинарная сериализация с валидацией, переменным размером индексов палитры (1–4 байта)
|
||||||
|
- **Слои** — добавление, удаление, переименование, порядок, видимость, прозрачность, дублирование
|
||||||
|
- **Документы/кадры** — несколько документов в одном контейнере, каждый со своей палитрой
|
||||||
|
- **Инструменты** — кисть, ластик (с регулируемым радиусом), заливка (flood fill), выделение + копирование/вставка
|
||||||
|
- **Холст** — pan (среднее колёсико / touchpad), zoom (Ctrl+колёсико), nearest-neighbor масштабирование, пиксельная сетка
|
||||||
|
- **Эффекты** — контрастность (A1), перевод в градации серого (A2) — применяются через палитру
|
||||||
|
- **Копирование фрагмента (A4)** — визуальное выделение на холсте, плавающая вставка с поддержкой смены документа/слоя
|
||||||
|
- **Генерация узоров (Б4)** — шахматка, градиент, полосы, концентрические круги, плитка
|
||||||
|
- **Анимация** — проигрывание документов как кадров с настраиваемым delay
|
||||||
|
- **Экспорт** — BMP (32-bit BGRA, самописный), GIF (анимированный, LZW, самописный)
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|------------|
|
||||||
|
| Язык | C# / .NET 10 |
|
||||||
|
| UI | Avalonia 11.3.8 |
|
||||||
|
| MVVM | CommunityToolkit.Mvvm 8.2.1 |
|
||||||
|
| Тесты | xUnit |
|
||||||
|
| Сериализация | Полностью самописная (бинарная) |
|
||||||
|
| Экспорт BMP/GIF | Полностью самописный |
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
Minint.slnx
|
||||||
|
├── Minint.Core/ — Доменные модели, интерфейсы сервисов, чистая логика
|
||||||
|
├── Minint.Infrastructure/ — Сериализация .minint, экспорт BMP/GIF
|
||||||
|
├── Minint/ — Avalonia UI приложение
|
||||||
|
└── Minint.Tests/ — Unit-тесты (xUnit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Core не зависит ни от каких внешних пакетов. Infrastructure зависит только от Core. UI зависит от Core + Infrastructure + Avalonia.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- [.NET 10 SDK](https://dotnet.microsoft.com/download) или новее
|
||||||
|
|
||||||
|
## Сборка и запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build
|
||||||
|
dotnet run --project Minint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
37 тестов покрывают: сериализацию (round-trip), композитинг, инструменты рисования, flood fill, эффекты (контраст, grayscale), генерацию паттернов, копирование фрагмента, экспорт BMP/GIF.
|
||||||
|
|
||||||
|
## Управление
|
||||||
|
|
||||||
|
| Действие | Управление |
|
||||||
|
|----------|------------|
|
||||||
|
| Зум | Ctrl + колёсико мыши |
|
||||||
|
| Перемещение (pan) | Среднее колёсико / touchpad scroll |
|
||||||
|
| Рисование | Левая кнопка мыши |
|
||||||
|
| Пиксельная сетка | Ctrl+G или View > Pixel Grid |
|
||||||
|
| Копирование фрагмента | Select tool → выделить → Ctrl+C → Ctrl+V → кликнуть для фиксации |
|
||||||
|
| Отмена вставки | Escape |
|
||||||
|
| Новый файл | Ctrl+N |
|
||||||
|
| Открыть | Ctrl+O |
|
||||||
|
| Сохранить | Ctrl+S |
|
||||||
|
|
||||||
|
## Формат `.minint`
|
||||||
|
|
||||||
|
Подробная спецификация формата — в файле [`FORMAT.md`](FORMAT.md).
|
||||||
|
|
||||||
|
Ключевые свойства:
|
||||||
|
- Little-endian
|
||||||
|
- Сигнатура `MININT`, версия формата
|
||||||
|
- Палитра RGBA на документ, индексы переменной ширины (1–4 байта)
|
||||||
|
- Полная валидация при чтении
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- **Undo/Redo** не реализовано (архитектура не запрещает добавление)
|
||||||
|
- **Размер контейнера** — все документы имеют одинаковые размеры (width × height на уровне контейнера)
|
||||||
|
- **GIF квантизация** — простая popularity-based (до 256 цветов), может терять оттенки
|
||||||
|
- **Максимальный размер** — 65536 × 65536 пикселей (ограничение формата)
|
||||||
|
- Имена документов/слоёв — максимум 255 UTF-8 байт
|
||||||
Reference in New Issue
Block a user