Этап 2

This commit is contained in:
2026-03-29 15:34:33 +03:00
parent 09d52aa973
commit 08b9039b58
19 changed files with 872 additions and 3 deletions

482
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,482 @@
# 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.