feat: Add theme and font management for egui GUI

- Introduced `theme.rs` to handle visual themes, font discovery, and application of theme colors.
- Implemented system font registration for Linux, macOS, and Windows.
- Added `apply_theme` function to configure egui visuals based on selected theme presets.

feat: Implement interactive tool state management

- Created `tool_state.rs` to manage runtime tool, selection, and transform states.
- Defined `ToolState` struct to encapsulate active tool, color settings, drawing states, and AI panel states.
- Included functionality for managing brush presets, text editing, and selection transformations.

feat: Add utility functions for image and file handling

- Developed `utils.rs` with stateless helper functions for color formatting, drawing icons, and cropping images.
- Implemented save dialog builders and error handling for file operations.
- Added flood fill functionality for composite RGBA pixels.

test: Add unit test for bitmap brush stamp functionality

- Created `bitmap_brush_stamp.rs` to verify the behavior of bitmap brush stamping in the engine.
- Ensured that the painted area matches expected dimensions and characteristics.
This commit is contained in:
2026-07-10 01:23:47 +03:00
parent e88a2be508
commit 07a9b35208
14 changed files with 2250 additions and 1523 deletions
@@ -3,6 +3,11 @@ use hcie_engine_api::brush::{BrushStyle, BrushTip};
use hcie_engine_api::BrushPreset;
use std::io::{Cursor, Read, Seek, SeekFrom};
/// Parse an ABR brush file and return a list of `BrushPreset` entries.
///
/// The ABR format stores sampled bitmap brushes in a `samp` resource block and
/// parametric/computed brushes in `computedB` markers. Brush names are extracted
/// from the `desc` block (which contains `Nm TEXT` tags) when available.
pub fn import_abr(data: &[u8]) -> Vec<BrushPreset> {
let mut presets = Vec::new();
let mut cursor = Cursor::new(data);
@@ -26,6 +31,12 @@ pub fn import_abr(data: &[u8]) -> Vec<BrushPreset> {
if end > start && end <= data.len() {
presets.extend(harvest_sampled_brushes(&data[start..end], start));
}
} else if tag == "Brs2" || tag == "brst" {
let start = cursor.position() as usize;
let end = block_end as usize;
if end > start && end <= data.len() {
presets.extend(harvest_brs2_sampled_brushes(&data[start..end], start));
}
}
pos = (block_end as usize + 1) & !1;
} else {
@@ -34,6 +45,10 @@ pub fn import_abr(data: &[u8]) -> Vec<BrushPreset> {
}
presets.extend(harvest_computed_brushes(data));
let desc_names = extract_desc_block_names(data);
apply_desc_names(&mut presets, &desc_names);
presets
}
@@ -133,10 +148,16 @@ fn try_decode_and_scale(
dst_w: u32,
dst_h: u32,
) -> Option<Vec<u8>> {
let decoded = decode_packbits_lenient(data, src_w * src_h);
if decoded.len() != src_w * src_h {
// Some ABR files store the width as (right - left) but the PackBits stream
// contains one extra padding column/row. Accept decoded data that is at least
// src_w * src_h and truncate any surplus instead of rejecting the brush.
let mut decoded = decode_packbits_lenient(data, src_w * src_h);
if decoded.len() < src_w * src_h {
return None;
}
if decoded.len() > src_w * src_h {
decoded.truncate(src_w * src_h);
}
if src_w as u32 == dst_w && src_h as u32 == dst_h {
return Some(decoded);
}
@@ -191,6 +212,86 @@ fn decode_packbits_lenient(data: &[u8], expected_size: usize) -> Vec<u8> {
out
}
/// Heuristic extraction for sampled brushes embedded in a `Brs2` / `brst`
/// resource block. Older ABR versions lay out the data as:
/// [count u32] for each brush [l t r b i32] [depth?] [name pascal]
/// [encoding byte?] [PackBits image data]
/// This is intentionally lenient so we do not crash on partially unknown formats.
fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec<BrushPreset> {
let mut presets = Vec::new();
if data.len() < 4 {
return presets;
}
let count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if count == 0 || count > 1000 {
return presets;
}
let mut i = 4usize;
for _ in 0..count {
if i + 16 > data.len() {
break;
}
let l = i32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
let t = i32::from_be_bytes([data[i + 4], data[i + 5], data[i + 6], data[i + 7]]);
let r = i32::from_be_bytes([data[i + 8], data[i + 9], data[i + 10], data[i + 11]]);
let b = i32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
i += 16;
let w = (r as i64 - l as i64).abs() as u32;
let h = (b as i64 - t as i64).abs() as u32;
if w == 0 || h == 0 || w > 10000 || h > 10000 {
continue;
}
// Skip depth/name/etc until we find an encoding signature or raw data.
// Try to locate a PackBits-like run near expected data offset.
let data_start = i;
let data_end = (i + (w * h * 2) as usize + 1024).min(data.len());
let slice = &data[data_start..data_end];
let (final_w, final_h) = if w > 1024 || h > 1024 {
let scale = 1024.0 / (w.max(h) as f32);
((w as f32 * scale) as u32, (h as f32 * scale) as u32)
} else {
(w, h)
};
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h) {
presets.push(BrushPreset {
id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h),
name: format!("Brush {}x{}", final_w, final_h),
category: "Imported (ABR)".to_string(),
tip: BrushTip {
style: BrushStyle::Bitmap,
size: final_w.max(final_h) as f32,
opacity: 1.0,
hardness: 1.0,
spacing: 0.1,
jitter_amount: 0.0,
scatter_amount: 0.0,
angle: 0.0,
roundness: 1.0,
spray_particle_size: 2.0,
spray_density: 100,
bitmap_pixels: decoded,
bitmap_w: final_w,
bitmap_h: final_h,
flow: 1.0,
color_variant: false,
variant_amount: 0.0,
density: 1.0,
drawing_angle: false,
rotation_random: 0.0,
},
style: BrushStyle::Bitmap,
pressure_size: true,
pressure_opacity: true,
pressure_flow: true,
jitter_position: 0.0,
jitter_angle: 0.0,
});
}
i = data_end;
}
presets
}
fn harvest_computed_brushes(data: &[u8]) -> Vec<BrushPreset> {
let mut presets = Vec::new();
let marker = b"computedB";
@@ -304,6 +405,227 @@ fn read_u32(cursor: &mut Cursor<&[u8]>) -> u32 {
u32::from_be_bytes(buf)
}
pub fn import_krita_preset(_data: &[u8]) -> Option<BrushPreset> {
None
/// Extract brush names from the `desc` block's `Nm TEXT` tags.
///
/// ABR files store a `desc` resource block that contains `BrshVlLs` (Brush
/// Value Lists). Each entry has a `Nm TEXT` field with the brush name in
/// UTF-16 encoding. The names appear in the same order as the brushes in the
/// `samp` block, so they can be associated by index.
fn extract_desc_block_names(data: &[u8]) -> Vec<String> {
let mut names = Vec::new();
let desc_pos = match find_bytes(data, b"8BIMdesc") {
Some(p) => p,
None => return names,
};
let size_pos = desc_pos + 8;
if size_pos + 4 > data.len() {
return names;
}
let size = u32::from_be_bytes([
data[size_pos],
data[size_pos + 1],
data[size_pos + 2],
data[size_pos + 3],
]) as usize;
let block_start = size_pos + 4;
let block_end = (block_start + size).min(data.len());
if block_end <= block_start {
return names;
}
let block = &data[block_start..block_end];
let mut idx = 0;
while idx < block.len() - 8 {
if &block[idx..idx + 8] == b"Nm TEXT" {
let p = idx + 8;
if p + 4 > block.len() {
break;
}
let nchars = u32::from_be_bytes([block[p], block[p + 1], block[p + 2], block[p + 3]]) as usize;
let text_start = p + 4;
if nchars == 0 || text_start + nchars * 2 > block.len() {
idx += 1;
continue;
}
let chars: Vec<u16> = block[text_start..text_start + nchars * 2]
.chunks_exact(2)
.map(|b| u16::from_be_bytes([b[0], b[1]]))
.collect();
let full = String::from_utf16_lossy(&chars);
let name = full
.split('=')
.last()
.unwrap_or(&full)
.trim_matches('\x00')
.to_string();
if !name.is_empty() {
names.push(name);
}
idx = text_start + nchars * 2;
} else {
idx += 1;
}
}
names
}
/// Apply names extracted from the `desc` block to presets.
///
/// The desc block names appear in the same order as sampled brushes were
/// collected. Computed brushes already have their names from `Nm TEXT` tags
/// inside the computed brush data, so only sampled presets (Bitmap style)
/// get updated.
fn apply_desc_names(presets: &mut [BrushPreset], desc_names: &[String]) {
if desc_names.is_empty() {
return;
}
let mut name_idx = 0;
for preset in presets.iter_mut() {
if preset.style == BrushStyle::Bitmap && name_idx < desc_names.len() {
let new_name = &desc_names[name_idx];
if !new_name.is_empty() {
preset.name = new_name.clone();
}
name_idx += 1;
}
}
}
/// Parse an ABR file and prefix generated IDs/names so imports from different
/// files do not collide.
pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec<BrushPreset> {
let mut presets = import_abr(data);
let safe_prefix = prefix.replace(' ', "_").replace('.', "_");
for preset in &mut presets {
preset.id = format!("{}_{}", safe_prefix, preset.id);
preset.name = format!("{} ({})", preset.name, safe_prefix);
}
presets
}
/// Parse a Krita brush preset (`.kpp`).
///
/// A `.kpp` file is a ZIP archive. This implementation extracts the `preview.png`
/// thumbnail, converts it to a grayscale alpha mask, and returns a single
/// `BrushStyle::Bitmap` preset.
pub fn import_kpp(data: &[u8], name_hint: &str) -> Option<BrushPreset> {
use std::io::Cursor;
use zip::ZipArchive;
let cursor = Cursor::new(data);
let mut zip = ZipArchive::new(cursor).ok()?;
let mut preview_bytes: Option<Vec<u8>> = None;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).ok()?;
let entry_name = entry.name().to_lowercase();
if entry_name.ends_with("preview.png") || entry_name == "preview.png" {
let mut buf = Vec::new();
if entry.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
preview_bytes = Some(buf);
break;
}
}
}
let preview_bytes = preview_bytes?;
let img = image::load_from_memory(&preview_bytes).ok()?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 {
return None;
}
// Downscale huge previews so GPU brush stamps stay cheap.
let (final_w, final_h) = if w > 512 || h > 512 {
let scale = 512.0 / (w.max(h) as f32);
((w as f32 * scale) as u32, (h as f32 * scale) as u32)
} else {
(w, h)
};
let mut gray = Vec::with_capacity((final_w * final_h) as usize);
for y in 0..final_h {
for x in 0..final_w {
let src_x = (x as f32 * (w as f32 / final_w as f32)) as u32;
let src_y = (y as f32 * (h as f32 / final_h as f32)) as u32;
let px = rgba.get_pixel(src_x.min(w - 1), src_y.min(h - 1));
// Use perceived luminance from RGB and keep full alpha range.
let l = (0.299 * px[0] as f32 + 0.587 * px[1] as f32 + 0.114 * px[2] as f32)
.clamp(0.0, 255.0) as u8;
let a = px[3];
// Mask value = luminance scaled by alpha.
gray.push((l as u16 * a as u16 / 255) as u8);
}
}
Some(BrushPreset {
id: format!("kpp_imported_{}_{}x{}", name_hint.replace(' ', "_"), final_w, final_h),
name: format!("{} (KPP)", name_hint),
category: "Imported".to_string(),
tip: BrushTip {
style: BrushStyle::Bitmap,
size: final_w.max(final_h) as f32,
opacity: 1.0,
hardness: 1.0,
spacing: 0.1,
jitter_amount: 0.0,
scatter_amount: 0.0,
angle: 0.0,
roundness: 1.0,
spray_particle_size: 2.0,
spray_density: 100,
bitmap_pixels: gray,
bitmap_w: final_w,
bitmap_h: final_h,
flow: 1.0,
color_variant: false,
variant_amount: 0.0,
density: 1.0,
drawing_angle: false,
rotation_random: 0.0,
},
style: BrushStyle::Bitmap,
pressure_size: true,
pressure_opacity: true,
pressure_flow: true,
jitter_position: 0.0,
jitter_angle: 0.0,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_desc_block_names() {
let path = "/mnt/data/Downloads/Square Brushes.abr";
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => {
eprintln!("Skipping test: {} not found", path);
return;
}
};
let names = extract_desc_block_names(&data);
assert!(!names.is_empty(), "Should extract names from desc block");
assert!(
names[0].contains("Hard Square"),
"First name should be descriptive, got: {}",
names[0]
);
}
#[test]
fn test_abr_import_with_names() {
let path = "/mnt/data/Downloads/Square Brushes.abr";
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => {
eprintln!("Skipping test: {} not found", path);
return;
}
};
let presets = import_abr(&data);
let bitmap: Vec<_> = presets.iter().filter(|p| p.style == BrushStyle::Bitmap).collect();
assert!(!bitmap.is_empty(), "Should have bitmap presets");
let has_names = bitmap.iter().any(|p| !p.name.starts_with("Brush "));
assert!(has_names, "Bitmap presets should have descriptive names");
}
}