feat(iced): add ABR brush import
This commit is contained in:
@@ -33,3 +33,4 @@ dirs = "5.0"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -283,6 +283,8 @@ pub struct ToolState {
|
||||
pub last_composite_refresh: std::time::Instant,
|
||||
/// Timestamp of the last update() call for frame timing.
|
||||
pub last_update_instant: std::time::Instant,
|
||||
/// Imported brush presets from ABR/KPP files.
|
||||
pub imported_brushes: Vec<hcie_engine_api::BrushPreset>,
|
||||
}
|
||||
|
||||
impl Default for ToolState {
|
||||
@@ -299,6 +301,7 @@ impl Default for ToolState {
|
||||
brush_spacing: 1.0,
|
||||
last_composite_refresh: std::time::Instant::now(),
|
||||
last_update_instant: std::time::Instant::now(),
|
||||
imported_brushes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,6 +387,8 @@ pub enum Message {
|
||||
BrushSizeChanged(f32),
|
||||
BrushOpacityChanged(f32),
|
||||
BrushHardnessChanged(f32),
|
||||
BrushImportAbr,
|
||||
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
|
||||
ResetToolDefaults,
|
||||
|
||||
// ── Filters ─────────────────────────────────────────
|
||||
@@ -1861,6 +1866,49 @@ impl HcieIcedApp {
|
||||
self.settings.apply_to_tool_state(&mut self.tool_state);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
Message::BrushImportAbr => {
|
||||
log::info!("Import ABR brush requested");
|
||||
let task = Task::perform(
|
||||
async {
|
||||
let result = rfd::AsyncFileDialog::new()
|
||||
.add_filter("ABR Brush Files", &["abr"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.set_title("Import ABR Brush")
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|handle| handle.path().to_path_buf());
|
||||
match result {
|
||||
Some(path) => {
|
||||
let file_name = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
match std::fs::read(&path) {
|
||||
Ok(data) => {
|
||||
let presets = crate::brush_import::import_abr_with_prefix(&data, &file_name);
|
||||
Ok(presets)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to read file: {}", e)),
|
||||
}
|
||||
}
|
||||
None => Err("No file selected".to_string()),
|
||||
}
|
||||
},
|
||||
|result| Message::BrushImportAbrFile(result),
|
||||
);
|
||||
return task;
|
||||
}
|
||||
Message::BrushImportAbrFile(result) => {
|
||||
match result {
|
||||
Ok(presets) => {
|
||||
log::info!("Imported {} brush presets", presets.len());
|
||||
// Store imported presets in the tool state
|
||||
self.tool_state.imported_brushes.extend(presets);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to import ABR brush: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filters ───────────────────────────────────
|
||||
Message::FilterSelect(filter) => {
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
//! ABR (Photoshop brush) file import module.
|
||||
//!
|
||||
//! Parses Photoshop `.abr` files and extracts:
|
||||
//! - Sampled bitmap brushes (PackBits encoded)
|
||||
//! - Computed/parametric brushes
|
||||
//!
|
||||
//! Returns `BrushPreset` entries that can be added to the brush library.
|
||||
|
||||
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.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Raw bytes of the ABR file
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of `BrushPreset` entries parsed from the file.
|
||||
pub fn import_abr(data: &[u8]) -> Vec<BrushPreset> {
|
||||
let mut presets = Vec::new();
|
||||
let mut cursor = Cursor::new(data);
|
||||
|
||||
let mut buf = [0u8; 4];
|
||||
if cursor.read_exact(&mut buf).is_err() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
while pos + 8 < data.len() {
|
||||
if &data[pos..pos + 4] == b"8BIM" {
|
||||
let tag = String::from_utf8_lossy(&data[pos + 4..pos + 8]).to_string();
|
||||
let _ = cursor.seek(SeekFrom::Start(pos as u64 + 8));
|
||||
let size = read_u32(&mut cursor);
|
||||
let block_end = (cursor.position() + size as u64).min(data.len() as u64);
|
||||
|
||||
if tag == "samp" {
|
||||
let start = cursor.position() as usize;
|
||||
let end = block_end as usize;
|
||||
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 {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
presets.extend(harvest_computed_brushes(data));
|
||||
|
||||
let desc_names = extract_desc_block_names(data);
|
||||
apply_desc_names(&mut presets, &desc_names);
|
||||
|
||||
presets
|
||||
}
|
||||
|
||||
fn safe_read_i32(data: &[u8], pos: usize) -> Option<i32> {
|
||||
if pos + 4 > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(i32::from_be_bytes([
|
||||
data[pos],
|
||||
data[pos + 1],
|
||||
data[pos + 2],
|
||||
data[pos + 3],
|
||||
]))
|
||||
}
|
||||
|
||||
fn harvest_sampled_brushes(data: &[u8], base_pos: usize) -> Vec<BrushPreset> {
|
||||
let mut presets = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 20 < data.len() {
|
||||
if data[i] == 8 && data[i + 1] == 1 {
|
||||
let sig_pos = if i > 0 && data[i - 1] == 0 { i - 1 } else { i };
|
||||
let mut found = false;
|
||||
if sig_pos >= 16 {
|
||||
let t = safe_read_i32(data, sig_pos - 16);
|
||||
let l = safe_read_i32(data, sig_pos - 12);
|
||||
let b = safe_read_i32(data, sig_pos - 8);
|
||||
let r = safe_read_i32(data, sig_pos - 4);
|
||||
if let (Some(t), Some(l), Some(b), Some(r)) = (t, l, b, r) {
|
||||
let h = (b as i64 - t as i64).abs() as u32;
|
||||
let w = (r as i64 - l as i64).abs() as u32;
|
||||
if w > 0 && h > 0 && w < 10000 && h < 10000 {
|
||||
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(
|
||||
&data[i + 2..],
|
||||
w as usize,
|
||||
h as usize,
|
||||
final_w,
|
||||
final_h,
|
||||
) {
|
||||
presets.push(BrushPreset {
|
||||
id: format!("abr_v6_{}_{}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,
|
||||
});
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
i += 20;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
presets
|
||||
}
|
||||
|
||||
fn try_decode_and_scale(
|
||||
data: &[u8],
|
||||
src_w: usize,
|
||||
src_h: usize,
|
||||
dst_w: u32,
|
||||
dst_h: u32,
|
||||
) -> Option<Vec<u8>> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
let mut scaled = Vec::with_capacity((dst_w * dst_h) as usize);
|
||||
for y in 0..dst_h {
|
||||
for x in 0..dst_w {
|
||||
let src_x = (x as f32 * (src_w as f32 / dst_w as f32)) as usize;
|
||||
let src_y = (y as f32 * (src_h as f32 / dst_h as f32)) as usize;
|
||||
scaled.push(decoded[src_y * src_w + src_x]);
|
||||
}
|
||||
}
|
||||
Some(scaled)
|
||||
}
|
||||
|
||||
fn decode_packbits_lenient(data: &[u8], expected_size: usize) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(expected_size);
|
||||
let mut i = 0;
|
||||
while i < data.len() && out.len() < expected_size {
|
||||
let n = data[i] as i8;
|
||||
i += 1;
|
||||
if n >= 0 {
|
||||
let count = n as usize + 1;
|
||||
if i + count <= data.len() {
|
||||
out.extend_from_slice(&data[i..i + count]);
|
||||
i += count;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if n != -128 {
|
||||
let count = (-n) as usize + 1;
|
||||
if i < data.len() {
|
||||
let val = data[i];
|
||||
i += 1;
|
||||
for _ in 0..count {
|
||||
out.push(val);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
while out.len() < expected_size {
|
||||
out.push(0);
|
||||
}
|
||||
if out.len() > expected_size {
|
||||
out.truncate(expected_size);
|
||||
}
|
||||
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";
|
||||
let mut search_pos = 0;
|
||||
while search_pos + marker.len() < data.len() {
|
||||
if &data[search_pos..search_pos + marker.len()] == marker {
|
||||
let window_end = (search_pos + 4096).min(data.len());
|
||||
let window = &data[search_pos..window_end];
|
||||
let name = parse_nm_text(window).unwrap_or_else(|| "Computed Brush".to_string());
|
||||
let diameter = parse_f64_field(window, b"Dmtr").unwrap_or(10.0);
|
||||
let hardness = parse_f64_field(window, b"Hrdn").unwrap_or(100.0);
|
||||
let spacing = parse_f64_field(window, b"Spcn").unwrap_or(25.0);
|
||||
if diameter > 0.0 && diameter < 5000.0 {
|
||||
presets.push(BrushPreset {
|
||||
id: format!("abr_comp_{:x}", search_pos),
|
||||
name,
|
||||
category: "Imported (ABR)".to_string(),
|
||||
tip: BrushTip {
|
||||
style: BrushStyle::Round,
|
||||
size: diameter as f32,
|
||||
opacity: 1.0,
|
||||
hardness: (hardness / 100.0).clamp(0.0, 1.0) as f32,
|
||||
spacing: (spacing / 100.0).clamp(0.01, 5.0) as f32,
|
||||
jitter_amount: 0.0,
|
||||
scatter_amount: 0.0,
|
||||
angle: 0.0,
|
||||
roundness: 1.0,
|
||||
spray_particle_size: 2.0,
|
||||
spray_density: 100,
|
||||
bitmap_pixels: Vec::new(),
|
||||
bitmap_w: 0,
|
||||
bitmap_h: 0,
|
||||
flow: 1.0,
|
||||
color_variant: false,
|
||||
variant_amount: 0.0,
|
||||
density: 1.0,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
},
|
||||
style: BrushStyle::Round,
|
||||
pressure_size: true,
|
||||
pressure_opacity: true,
|
||||
pressure_flow: true,
|
||||
jitter_position: 0.0,
|
||||
jitter_angle: 0.0,
|
||||
});
|
||||
}
|
||||
search_pos += marker.len();
|
||||
} else {
|
||||
search_pos += 1;
|
||||
}
|
||||
}
|
||||
presets
|
||||
}
|
||||
|
||||
fn parse_nm_text(data: &[u8]) -> Option<String> {
|
||||
let tag = b"Nm TEXT";
|
||||
let pos = find_bytes(data, tag)?;
|
||||
let p = pos + tag.len();
|
||||
if p + 4 > data.len() {
|
||||
return None;
|
||||
}
|
||||
let nchars = u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]) as usize;
|
||||
let p = p + 4;
|
||||
if p + nchars * 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
let chars: Vec<u16> = data[p..p + 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() {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_f64_field(data: &[u8], tag: &[u8]) -> Option<f64> {
|
||||
let pos = find_bytes(data, tag)?;
|
||||
let p = pos + tag.len() + 8;
|
||||
if p + 8 > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(f64::from_be_bytes([
|
||||
data[p],
|
||||
data[p + 1],
|
||||
data[p + 2],
|
||||
data[p + 3],
|
||||
data[p + 4],
|
||||
data[p + 5],
|
||||
data[p + 6],
|
||||
data[p + 7],
|
||||
]))
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
fn read_u32(cursor: &mut Cursor<&[u8]>) -> u32 {
|
||||
let mut buf = [0u8; 4];
|
||||
let _ = cursor.read_exact(&mut buf);
|
||||
u32::from_be_bytes(buf)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Raw bytes of the ABR file
|
||||
/// * `prefix` - Prefix to add to all preset IDs and names
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of `BrushPreset` entries with prefixed IDs and names.
|
||||
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.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Raw bytes of the KPP file
|
||||
/// * `name_hint` - Hint for the brush name (usually the filename)
|
||||
///
|
||||
/// # Returns
|
||||
/// Optional `BrushPreset` if parsing succeeds.
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
mod ai_chat;
|
||||
mod ai_script;
|
||||
mod app;
|
||||
mod brush_import;
|
||||
mod canvas;
|
||||
mod color_picker;
|
||||
mod dialogs;
|
||||
|
||||
@@ -331,6 +331,27 @@ pub fn view(
|
||||
horizontal_rule(1),
|
||||
scrollable(grid_rows).height(Length::Fill),
|
||||
horizontal_rule(1),
|
||||
row![
|
||||
button(text("Import ABR").size(10))
|
||||
.on_press(Message::BrushImportAbr)
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.accent)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
].spacing(4),
|
||||
text("Preview").size(10),
|
||||
live_preview,
|
||||
text(format!("Size: {:.0}", current_size)).size(10),
|
||||
|
||||
Reference in New Issue
Block a user