Files
Minint/ARCHITECTURE.md
2026-03-29 15:34:33 +03:00

483 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```csharp
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
```csharp
public sealed class MinintLayer
{
public string Name { get; set; }
public bool IsVisible { get; set; }
public byte Opacity { get; set; } // 0255, reserved for future blending
public int[] Pixels { get; } // length = Width * Height, row-major
}
```
- `Pixels[y * width + x]` = index into the parent document's palette.
- `int` chosen over `uint` because C# array indexing is int-based; max value 2³¹1 is more than sufficient.
- On-disk packing (14 bytes per index) is handled only by the serializer.
### 2.3 MinintDocument
```csharp
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.Transparent` by convention (the eraser writes 0).
- When a new document is created, the palette is initialized with at least `[Transparent]`.
### 2.4 MinintContainer
```csharp
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 (0255) |
| W×H×BytesPerIndex | bytes | Pixel indices, row-major, LE per idx |
### 3.3 Validation Rules
1. Signature must be exactly `MININT`.
2. Version must be `1` (future-proof: reject unknown versions).
3. Width, Height ≥ 1.
4. DocumentCount ≥ 1.
5. PaletteCount ≥ 1 (at least the transparent color).
6. Every pixel index must be `< PaletteCount`.
7. Reserved bytes must be zero (warn on non-zero for forward compat).
8. 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 `colorIndex` to 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):
1. Scan all layers, collect used indices into a `HashSet<int>`.
2. Build new palette = only used colors (keep index 0 = transparent).
3. Build old→new index mapping.
4. 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
1. Composite document into RGBA buffer.
2. Write BMP file header (14 bytes) + DIB BITMAPINFOHEADER (40 bytes).
3. Write pixel data as 32-bit BGRA, bottom-up row order.
4. No compression, no palette section (direct 32-bit).
### 5.9 GIF Export
1. For each document in container, composite into RGBA buffer.
2. Use external library (e.g. `SixLabors.ImageSharp`) to encode frames
with per-frame delay from `FrameDelayMs`.
3. 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 `DrawImage` with `BitmapInterpolationMode.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.