18 KiB
Minint — Architecture Document (Stage 1)
1. Solution Structure
Minint.slnx
├── Minint.Core/ — Domain models, interfaces, pure logic (no UI, no I/O)
│ └── Minint.Core.csproj (net10.0, classlib)
├── Minint.Infrastructure/ — Binary serialization, BMP/GIF export
│ └── Minint.Infrastructure.csproj (net10.0, classlib, references Core)
├── Minint/ — Avalonia UI application
│ └── Minint.csproj (net10.0, WinExe, references Core + Infrastructure)
└── Minint.Tests/ — Unit tests (added later if needed)
└── Minint.Tests.csproj (net10.0, xunit, references Core + Infrastructure)
Dependency graph
Minint (UI) ──► Minint.Core
│
└──────► Minint.Infrastructure ──► Minint.Core
UI depends on Core and Infrastructure. Infrastructure depends on Core only. Core has zero external dependencies (pure domain).
2. RAM Model (Domain Objects)
2.1 RgbaColor
public readonly record struct RgbaColor(byte R, byte G, byte B, byte A)
{
public static readonly RgbaColor Transparent = new(0, 0, 0, 0);
}
Value type. Equality by all four channels (record struct gives this for free).
2.2 MinintLayer
public sealed class MinintLayer
{
public string Name { get; set; }
public bool IsVisible { get; set; }
public byte Opacity { get; set; } // 0–255, reserved for future blending
public int[] Pixels { get; } // length = Width * Height, row-major
}
Pixels[y * width + x]= index into the parent document's palette.intchosen overuintbecause C# array indexing is int-based; max value 2³¹−1 is more than sufficient.- On-disk packing (1–4 bytes per index) is handled only by the serializer.
2.3 MinintDocument
public sealed class MinintDocument
{
public string Name { get; set; }
public uint FrameDelayMs { get; set; } // animation delay
public List<RgbaColor> Palette { get; } // shared by all layers
public List<MinintLayer> Layers { get; }
}
- Palette is per-document, shared by all layers of that document.
- Index 0 is always
RgbaColor.Transparentby convention (the eraser writes 0). - When a new document is created, the palette is initialized with at least
[Transparent].
2.4 MinintContainer
public sealed class MinintContainer
{
public int Width { get; set; }
public int Height { get; set; }
public List<MinintDocument> Documents { get; }
}
- Width/Height at the container level: all documents and all layers share the same dimensions. This is required for coherent animation playback and GIF export.
2.5 Diagram
MinintContainer
├─ Width, Height
└─ Documents: List<MinintDocument>
├─ Name, FrameDelayMs
├─ Palette: List<RgbaColor> ← shared by layers
└─ Layers: List<MinintLayer>
├─ Name, IsVisible, Opacity
└─ Pixels: int[] ← indices into Palette
3. Binary Format .minint
All multi-byte integers are little-endian. Strings are UTF-8, prefixed by a 1-byte length (max 255 chars for names).
3.1 Container Header (28 bytes fixed)
| Offset | Size | Type | Description |
|---|---|---|---|
| 0 | 6 | ASCII | Signature: MININT |
| 6 | 2 | uint16 | Format version (currently 1) |
| 8 | 4 | uint32 | Width |
| 12 | 4 | uint32 | Height |
| 16 | 4 | uint32 | DocumentCount |
| 20 | 8 | — | Reserved (must be zero) |
3.2 Document Block (repeated DocumentCount times)
Document Header
| Offset (relative) | Size | Type | Description |
|---|---|---|---|
| 0 | 1 | uint8 | NameLength |
| 1 | NameLength | UTF-8 | Name |
| 1+NameLength | 4 | uint32 | FrameDelayMs |
| 5+NameLength | 4 | uint32 | PaletteCount |
Palette (immediately follows)
PaletteCount × 4 bytes: each color is [R, G, B, A] (1 byte each).
Index Byte Width
Derived from PaletteCount (not stored explicitly — reader computes it):
| PaletteCount | BytesPerIndex |
|---|---|
| 1 – 255 | 1 |
| 256 – 65 535 | 2 |
| 65 536 – 16 777 215 | 3 |
| 16 777 216+ | 4 |
Layer Count
| Size | Type | Description |
|---|---|---|
| 4 | uint32 | LayerCount |
Layer Block (repeated LayerCount times)
| Size | Type | Description |
|---|---|---|
| 1 | uint8 | LayerNameLength |
| LayerNameLength | UTF-8 | LayerName |
| 1 | uint8 | IsVisible (0 = hidden, 1 = visible) |
| 1 | uint8 | Opacity (0–255) |
| W×H×BytesPerIndex | bytes | Pixel indices, row-major, LE per idx |
3.3 Validation Rules
- Signature must be exactly
MININT. - Version must be
1(future-proof: reject unknown versions). - Width, Height ≥ 1.
- DocumentCount ≥ 1.
- PaletteCount ≥ 1 (at least the transparent color).
- Every pixel index must be
< PaletteCount. - Reserved bytes must be zero (warn on non-zero for forward compat).
- Unexpected EOF → error with clear message.
3.4 Format Size Estimate
For a 64×64 image, 1 document, 1 layer, 16-color palette:
- Header: 28 bytes
- Doc header: ~10 bytes
- Palette: 16 × 4 = 64 bytes
- Layer header: ~8 bytes
- Pixel data: 64 × 64 × 1 = 4 096 bytes
- Total ≈ 4.2 KB
4. Classes, Interfaces, and Services
4.1 Core — Domain Models
| Type | Kind | Responsibility |
|---|---|---|
RgbaColor |
record struct | Immutable RGBA color value |
MinintLayer |
class | Single raster layer (index buffer) |
MinintDocument |
class | Document with palette + layers |
MinintContainer |
class | Top-level container of documents |
4.2 Core — Interfaces
| Interface | Methods (key) |
|---|---|
ICompositor |
uint[] Composite(MinintDocument, int w, int h) |
IPaletteService |
int EnsureColor(doc, RgbaColor), void CompactPalette(doc) |
IDrawingService |
void ApplyBrush(layer, x, y, radius, colorIdx, w, h) |
IFloodFillService |
void Fill(layer, x, y, newColorIdx, w, h) |
IImageEffectService |
void AdjustContrast(doc, float factor), void ToGrayscale(doc) |
IPatternGenerator |
MinintDocument Generate(PatternParams) |
IFragmentService |
void CopyFragment(src, dst, rect, destPoint) |
4.3 Core — Service Implementations
| Class | Implements | Notes |
|---|---|---|
Compositor |
ICompositor |
Alpha-blends layers bottom→top |
PaletteService |
IPaletteService |
Color lookup, add, compact |
DrawingService |
IDrawingService |
Circle mask brush/eraser |
FloodFillService |
IFloodFillService |
BFS flood fill on index array |
ImageEffectService |
IImageEffectService |
Palette-based contrast/grayscale |
PatternGenerator |
IPatternGenerator |
Checkerboard, gradient, stripes… |
FragmentService |
IFragmentService |
Copy rect with palette merging |
4.4 Infrastructure
| Class | Responsibility |
|---|---|
MinintSerializer |
Read/write .minint binary format |
BmpExporter |
Export composited document to 32-bit BMP |
GifExporter |
Export all documents as animated GIF (uses lib) |
4.5 UI (Avalonia) — Key Types
| Type | Kind | Responsibility |
|---|---|---|
MainWindowViewModel |
ViewModel | Orchestrates the editor |
EditorViewModel |
ViewModel | Current document, tool state, viewport |
DocumentListViewModel |
ViewModel | Document tabs / frame list |
LayerListViewModel |
ViewModel | Layer panel |
ToolSettingsViewModel |
ViewModel | Brush size, color, tool type |
PixelCanvas |
Control | Custom Avalonia control for rendering |
Viewport |
Model | Zoom, offset, coord transforms |
5. Key Algorithms
5.1 Compositing (layers → RGBA buffer)
result = new uint[W * H] (RGBA packed as uint, initialized to transparent)
for each layer (bottom → top):
if !layer.IsVisible: skip
for each pixel i in 0..W*H-1:
srcColor = palette[layer.Pixels[i]]
effectiveAlpha = srcColor.A * layer.Opacity / 255
if effectiveAlpha == 0: continue
result[i] = AlphaBlend(result[i], srcColor with A=effectiveAlpha)
Alpha blending (standard "over" operator):
outA = srcA + dstA * (255 - srcA) / 255
outR = (srcR * srcA + dstR * dstA * (255 - srcA) / 255) / outA
(same for G, B)
5.2 Brush / Eraser
Circle mask: for each pixel (px, py) within bounding box of (cx ± r, cy ± r):
if (px - cx)² + (py - cy)² <= r²:
mark pixel as affected
- Brush: writes
colorIndexto affected pixels. - Eraser: writes
0(transparent index) to affected pixels. - Preview: compute mask, overlay on canvas as semi-transparent highlight without modifying pixel data.
5.3 Flood Fill (BFS)
targetIdx = layer.Pixels[y * W + x]
if targetIdx == fillIdx: return
queue = { (x, y) }
visited = bitset of W * H
while queue not empty:
(cx, cy) = dequeue
if out of bounds or visited: continue
if layer.Pixels[cy * W + cx] != targetIdx: continue
mark visited
layer.Pixels[cy * W + cx] = fillIdx
enqueue 4-neighbors
5.4 Palette Compaction
On save (or on demand):
- Scan all layers, collect used indices into a
HashSet<int>. - Build new palette = only used colors (keep index 0 = transparent).
- Build old→new index mapping.
- Remap all pixel arrays.
5.5 Copy Fragment Between Documents (A4)
for each pixel (sx, sy) in source rect:
srcIdx = srcLayer.Pixels[sy * W + sx]
srcColor = srcDoc.Palette[srcIdx]
dstIdx = dstDoc.PaletteService.EnsureColor(srcColor)
dstLayer.Pixels[destY * W + destX] = dstIdx
destX++; ...
EnsureColor checks if color exists in target palette; if not, appends it and returns the new index.
5.6 Contrast (A1)
Applied to palette (not individual pixels):
for each color in palette (skip index 0 transparent):
R' = clamp(factor * (R - 128) + 128, 0, 255)
G' = clamp(factor * (G - 128) + 128, 0, 255)
B' = clamp(factor * (B - 128) + 128, 0, 255)
A' = A (unchanged)
factor > 1 = increase contrast, factor < 1 = decrease.
This is correct because all pixels reference the palette; modifying palette entries globally changes the image. No pixel remapping needed.
Caveat: if two palette entries become identical after transformation, they could be merged (optional optimization).
5.7 Grayscale (A2)
Applied to palette:
for each color in palette (skip index 0 transparent):
gray = (byte)(0.299 * R + 0.587 * G + 0.114 * B)
color = RgbaColor(gray, gray, gray, A)
Uses ITU-R BT.601 luminance formula (standard for perceptual grayscale).
Same caveat about duplicate entries applies.
5.8 BMP Export
- Composite document into RGBA buffer.
- Write BMP file header (14 bytes) + DIB BITMAPINFOHEADER (40 bytes).
- Write pixel data as 32-bit BGRA, bottom-up row order.
- No compression, no palette section (direct 32-bit).
5.9 GIF Export
- For each document in container, composite into RGBA buffer.
- Use external library (e.g.
SixLabors.ImageSharp) to encode frames with per-frame delay fromFrameDelayMs. - Output as animated GIF.
Only the GIF encoder is external; the .minint format is fully self-implemented.
5.10 Pattern Generation (Б4)
Factory-based: PatternType enum + parameters struct per pattern type.
| Pattern | Parameters |
|---|---|
| Checkerboard | cellSize, color1, color2 |
| Gradient (H/V) | startColor, endColor, direction |
| Stripes (H/V) | stripeWidth, color1, color2 |
| Concentric Circles | centerX, centerY, ringWidth, colors |
| Tile | tileWidth, tileHeight, colors |
Each generator creates a MinintDocument with an appropriate palette and a single layer.
6. Viewport and Canvas Design
Viewport:
Zoom: double (1.0 = 1 pixel = 1 screen pixel)
OffsetX, OffsetY: double (pan position)
ScreenToPixel(screenX, screenY) → (pixelX, pixelY)
pixelX = (int)floor((screenX - OffsetX) / Zoom)
pixelY = (int)floor((screenY - OffsetY) / Zoom)
PixelToScreen(pixelX, pixelY) → (screenX, screenY)
screenX = pixelX * Zoom + OffsetX
screenY = pixelY * Zoom + OffsetY
Canvas (PixelCanvas custom control):
- Renders composited bitmap using
DrawImagewithBitmapInterpolationMode.None(nearest-neighbor). - Overlays pixel grid (vertical + horizontal lines) when zoom > threshold and grid enabled.
- Overlays brush/eraser preview as semi-transparent mask.
- Handles mouse events: wheel → zoom, middle-drag → pan, left-click/drag → tool action.
7. Key Decisions and Trade-offs
D1. Width/Height at container level
Chosen: container-level (all documents share dimensions). Rationale: required for coherent animation; GIF frames must be same size. Trade-off: cannot mix different-sized documents in one container.
D2. int[] for pixel indices in RAM
Chosen: int[] (4 bytes per pixel in memory).
Rationale: simple, fast, native array indexing; on-disk packing handles storage efficiency.
Trade-off: memory usage is 4× compared to byte[] for small palettes, but acceptable for typical image sizes (e.g., 1024×1024 = 4 MB).
D3. Transparent color at palette index 0
Chosen: convention, always enforced. Rationale: simplifies eraser (write 0), new layer initialization (fill with 0), and compositing (skip transparent early). Trade-off: first palette slot is reserved; user cannot reassign it.
D4. Grayscale / contrast via palette transformation
Chosen: modify palette entries directly. Rationale: elegant — all pixels update automatically; no per-pixel remapping. Trade-off: operation is document-wide (affects all layers). Per-layer effects would require palette duplication or a different approach. For this project scope, document-wide is acceptable and explicitly chosen.
D5. Layer opacity in compositing
Chosen: multiply palette alpha by layer opacity byte.
Rationale: opens path for future layer blending without breaking anything now.
Default: new layers have Opacity = 255 (fully opaque).
D6. Eraser semantics
Chosen: eraser writes index 0 (transparent). Rationale: consistent with palette convention; compositing naturally handles it.
D7. GIF export library
Chosen: external library for GIF encoding only (SixLabors.ImageSharp or equivalent).
Rationale: GIF LZW compression is complex and not the focus of this project.
Constraint: .minint serialization remains fully self-implemented.
D8. No undo/redo in initial implementation
Chosen: not implemented in first pass; architecture does not prevent it. Path forward: command pattern or snapshot-based undo can be layered on later.
8. Staged Implementation Plan
| Stage | Scope |
|---|---|
| 1 | ✅ Architecture & design (this document) |
| 2 | Solution scaffold + domain models |
| 3 | Binary .minint serialization + round-trip test |
| 4 | Compositing + palette management + RGBA buffer |
| 5 | Basic Avalonia UI (main window, menus, panels) |
| 6 | Canvas: pan, zoom, grid, nearest-neighbor |
| 7 | Drawing tools: brush, eraser, flood fill, preview |
| 8 | Layer & document management UI |
| 9 | Effects: contrast, grayscale, fragment copy, patterns |
| 10 | Animation playback + BMP/GIF export |
| 11 | Polish, tests, documentation |
9. External Dependencies
| Package | Project | Purpose |
|---|---|---|
| Avalonia 11.3.8 | Minint (UI) | UI framework |
| Avalonia.Desktop 11.3.8 | Minint (UI) | Desktop host |
| Avalonia.Themes.Fluent 11.3.8 | Minint (UI) | Theme |
| CommunityToolkit.Mvvm 8.2.1 | Minint (UI) | MVVM helpers |
| SixLabors.ImageSharp (TBD) | Infrastructure | GIF export only |
Core project: zero external dependencies.