mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 23:24:07 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15f045a2e | ||
|
|
13ba3da4b4 | ||
|
|
80d30e8f58 | ||
|
|
b169a718e1 | ||
|
|
a4fdcd92c7 |
No files matched your search
Generated
+1
@@ -3479,6 +3479,7 @@ dependencies = [
|
||||
"photocraft-doc",
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-paint",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
|
||||
@@ -29,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent"] }
|
||||
@@ -11,10 +11,11 @@ use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft documents, Photoshop documents and flat images.
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
@@ -37,6 +38,7 @@ pub fn start() {
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let pen_target = canvas.clone();
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
@@ -45,6 +47,7 @@ pub fn start() {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let inbox: Inbox = Arc::default();
|
||||
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
listen_pen(&pen_target, app.stylus.feed.clone());
|
||||
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& !force_cpu
|
||||
@@ -65,6 +68,28 @@ pub fn start() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
|
||||
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
|
||||
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
|
||||
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
|
||||
use photocraft_ui_egui::stylus::PenSample;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
|
||||
let feed = feed.clone();
|
||||
let cb = Closure::<dyn FnMut(web_sys::PointerEvent)>::new(move |e: web_sys::PointerEvent| {
|
||||
let ty = e.type_();
|
||||
if ty == "pointerup" && e.pointer_type() == "pen" {
|
||||
return;
|
||||
}
|
||||
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
|
||||
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
|
||||
});
|
||||
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
|
||||
cb.forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft documents, Photoshop documents and flat images.
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
|
||||
@@ -24,6 +24,7 @@ photocraft-algo = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
photocraft-io = { workspace = true }
|
||||
photocraft-psd = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
photocraft-plugins = { workspace = true }
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
|
||||
@@ -327,7 +327,7 @@ fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(patch) => merge_brush(&s.tools.brush, patch, cmd)?,
|
||||
None => s.tools.brush.clone(),
|
||||
};
|
||||
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false });
|
||||
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new() });
|
||||
Ok(json!({ "name": name, "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let tip = GrayTile::from_f32(tw, th, &data);
|
||||
let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() };
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false });
|
||||
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new() });
|
||||
s.tools.brush = brush;
|
||||
Ok(json!({ "name": name, "width": tw, "height": th }))
|
||||
}
|
||||
|
||||
@@ -943,6 +943,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::color_cmds::specs());
|
||||
v.extend(crate::brush_cmds::specs());
|
||||
v.extend(crate::eraser_cmds::specs());
|
||||
v.extend(crate::preset_import_cmds::specs());
|
||||
v.extend(crate::retouch_cmds::specs());
|
||||
v.extend(crate::image_cmds::specs());
|
||||
v.extend(crate::selection_cmds::specs());
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod pick_cmds;
|
||||
mod pixels;
|
||||
pub mod plugin_cmds;
|
||||
pub mod prefs;
|
||||
pub mod preset_import_cmds;
|
||||
pub mod presets;
|
||||
pub mod print_cmds;
|
||||
pub mod proof_sim;
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Importing Photoshop preset files.
|
||||
//!
|
||||
//! - `brush.presets.importAbr` adds the presets of an `.abr` file (v1, v2, v6+) to the brush
|
||||
//! library as one group (Brushes panel › Import Brushes…, Preset Manager). Parsing lives in
|
||||
//! `photocraft-psd` (`abr`), the mapping onto [`BrushSettings`] in `photocraft-io` (`abr_map`).
|
||||
//! - `gradient.presets.importGrd` adds the gradients of a `.grd` file (version 5) to the
|
||||
//! Gradients panel as one group.
|
||||
//!
|
||||
//! [`BrushSettings`]: photocraft_paint::BrushSettings
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest accepted file (Photoshop's biggest bundled sets are well under this).
|
||||
pub const MAX_FILE_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// File bytes from `data` (base64) or `path`, plus a default group name.
|
||||
pub(crate) fn file_bytes(p: &Value, cmd: &str, what: &str) -> Result<(Vec<u8>, String)> {
|
||||
if let Some(d) = p.get("data") {
|
||||
let s = d.as_str().ok_or_else(|| bad(cmd, "`data` must be a base64 string"))?;
|
||||
if s.len() as u64 > MAX_FILE_BYTES / 3 * 4 + 4 {
|
||||
return Err(bad(cmd, "`data` is too large"));
|
||||
}
|
||||
let bytes = photocraft_paint::tile::b64_decode(s).ok_or_else(|| bad(cmd, "`data` is not valid base64"))?;
|
||||
return Ok((bytes, String::new()));
|
||||
}
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, format!("give `path` ({what}) or `data` (base64)")))?;
|
||||
let len = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.len();
|
||||
if len > MAX_FILE_BYTES {
|
||||
return Err(bad(cmd, format!("{path}: file too large ({len} bytes)")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
Ok((bytes, stem))
|
||||
}
|
||||
|
||||
/// A name not used by any preset in `taken` (case-insensitive): `name`, `name 2`, `name 3`…
|
||||
fn unique(name: &str, taken: &[String]) -> String {
|
||||
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
|
||||
if free(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.importAbr";
|
||||
let (bytes, stem) = file_bytes(p, cmd, ".abr file")?;
|
||||
let group = match p.get("group") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(),
|
||||
None if !stem.is_empty() => stem,
|
||||
None => "Imported Brushes".to_string(),
|
||||
};
|
||||
let imp = photocraft_io::abr_map::read_abr(&bytes, &group).map_err(|e| bad(cmd, e))?;
|
||||
// Re-importing a file replaces its group instead of duplicating it.
|
||||
if p.get("replace").and_then(Value::as_bool).unwrap_or(true) {
|
||||
s.tools.presets.retain(|x| x.builtin || x.group != group);
|
||||
}
|
||||
let mut taken: Vec<String> = s.tools.presets.iter().map(|x| x.name.clone()).collect();
|
||||
let mut names = Vec::with_capacity(imp.presets.len());
|
||||
for mut preset in imp.presets {
|
||||
preset.name = unique(&preset.name, &taken);
|
||||
taken.push(preset.name.clone());
|
||||
names.push(preset.name.clone());
|
||||
s.tools.presets.push(preset);
|
||||
}
|
||||
if p.get("select").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Some(first) = names.first()
|
||||
&& let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first)
|
||||
{
|
||||
s.tools.brush = pr.brush.clone();
|
||||
}
|
||||
Ok(json!({ "group": group, "imported": names, "count": names.len(), "version": imp.version, "warnings": imp.warnings }))
|
||||
}
|
||||
|
||||
/// Brush-file import command specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "brush.presets.importAbr",
|
||||
label: "Import Brushes…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":".abr file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name),"select":bool=false (make the first imported preset current)}"##,
|
||||
enabled: always,
|
||||
run: import_abr,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "gradient.presets.importGrd",
|
||||
label: "Import Gradients…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":".grd file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name)}"##,
|
||||
enabled: always,
|
||||
run: import_grd,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ gradients (.grd)
|
||||
|
||||
fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||
let h = h.rem_euclid(360.0) / 60.0;
|
||||
let c = v * s;
|
||||
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
|
||||
let (r, g, b) = match h as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = v - c;
|
||||
[r + m, g + m, b + m]
|
||||
}
|
||||
|
||||
/// CIE Lab (D50, as Photoshop stores it) to sRGB through XYZ with Bradford adaptation to D65.
|
||||
fn lab_to_rgb([l, a, b]: [f32; 3]) -> [f32; 3] {
|
||||
let fy = (l + 16.0) / 116.0;
|
||||
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
|
||||
let inv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||
let (x, y, z) = (0.9642 * inv(fx), inv(fy), 0.8249 * inv(fz));
|
||||
// XYZ (D50) → linear sRGB (D65), Bradford-adapted matrix.
|
||||
let r = 3.1339 * x - 1.6169 * y - 0.4906 * z;
|
||||
let g = -0.9788 * x + 1.9161 * y + 0.0335 * z;
|
||||
let bb = 0.0719 * x - 0.2290 * y + 1.4052 * z;
|
||||
let gamma = |c: f32| {
|
||||
let c = c.clamp(0.0, 1.0);
|
||||
if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
|
||||
};
|
||||
[gamma(r), gamma(g), gamma(bb)]
|
||||
}
|
||||
|
||||
fn stop_color(c: photocraft_psd::grd::GrdColor) -> crate::presets::gradients::StopColor {
|
||||
use crate::presets::gradients::StopColor;
|
||||
use photocraft_psd::grd::GrdColor;
|
||||
let clamp = |c: [f32; 3]| StopColor::Rgb(c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }));
|
||||
match c {
|
||||
GrdColor::Foreground => StopColor::Foreground,
|
||||
GrdColor::Background => StopColor::Background,
|
||||
GrdColor::Rgb(c) => clamp(c),
|
||||
GrdColor::Hsb(c) => clamp(hsb_to_rgb(c)),
|
||||
// Naive (profile-free) ink → RGB, like an uncalibrated preview.
|
||||
GrdColor::Cmyk([c, m, y, k]) => clamp([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]),
|
||||
GrdColor::Lab(c) => clamp(lab_to_rgb(c)),
|
||||
GrdColor::Gray(g) => clamp([1.0 - g; 3]),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_grd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use crate::presets::{Group, gradients::GradientPreset};
|
||||
let cmd = "gradient.presets.importGrd";
|
||||
let (bytes, stem) = file_bytes(p, cmd, ".grd file")?;
|
||||
let group = match p.get("group") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(cmd, "`group` must be a non-empty string"))?.to_string(),
|
||||
None if !stem.is_empty() => stem,
|
||||
None => "Imported Gradients".to_string(),
|
||||
};
|
||||
let grads = photocraft_psd::grd::parse(&bytes).map_err(|e| bad(cmd, format!("not a readable Photoshop gradient file: {e}")))?;
|
||||
let mut warnings = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
let (mut noise, mut midpoints) = (0, false);
|
||||
for (i, g) in grads.iter().enumerate() {
|
||||
if g.noise || g.stops.len() < 2 {
|
||||
noise += 1;
|
||||
continue;
|
||||
}
|
||||
midpoints |= g.stops.iter().any(|st| (st.midpoint - 0.5).abs() > 0.01) || g.opacity.iter().any(|o| (o.2 - 0.5).abs() > 0.01);
|
||||
let mut stops: Vec<(f32, _)> = g.stops.iter().map(|st| (st.location, stop_color(st.color))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut opacity: Vec<(f32, f32)> = g.opacity.iter().map(|o| (o.0, o.1)).collect();
|
||||
opacity.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if opacity.iter().all(|o| o.1 >= 1.0) {
|
||||
opacity.clear();
|
||||
}
|
||||
let name = if g.name.trim().is_empty() { format!("Gradient {}", i + 1) } else { g.name.trim().to_string() };
|
||||
items.push(GradientPreset { name, stops, opacity });
|
||||
}
|
||||
if noise > 0 {
|
||||
warnings.push(format!("{noise} noise gradients are not supported and were skipped"));
|
||||
}
|
||||
if midpoints {
|
||||
warnings.push("colour and opacity midpoints other than 50 % are drawn at 50 %".to_string());
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(bad(cmd, "the file holds no gradients PhotoCraft can use (noise gradients only)"));
|
||||
}
|
||||
let names: Vec<String> = items.iter().map(|g| g.name.clone()).collect();
|
||||
let st = &mut s.presets;
|
||||
if p.get("replace").and_then(Value::as_bool).unwrap_or(true) {
|
||||
st.gradients.retain(|g| g.name != group);
|
||||
}
|
||||
st.gradients.push(Group::new(&group, items));
|
||||
s.presets_changed();
|
||||
Ok(json!({"group": group, "imported": names, "count": names.len(), "warnings": warnings}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v6, write_v12};
|
||||
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as DV};
|
||||
|
||||
fn abr_v2() -> Vec<u8> {
|
||||
let tip = AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] };
|
||||
write_v12(
|
||||
2,
|
||||
&[
|
||||
LegacyBrush { name: "Hard Round".into(), spacing: 25, anti_alias: true, tip: LegacyTip::Sampled(tip) },
|
||||
LegacyBrush {
|
||||
name: String::new(),
|
||||
spacing: 10,
|
||||
anti_alias: true,
|
||||
tip: LegacyTip::Computed { diameter: 9, hardness: 100, angle: 0, roundness: 100 },
|
||||
},
|
||||
],
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_a_group_and_paints() {
|
||||
let mut s = Session::new();
|
||||
let before = s.tools.presets.len();
|
||||
let data = photocraft_paint::tile::b64_encode(&abr_v2());
|
||||
let r = s.execute("brush.presets.importAbr", json!({"data": data, "group": "Legacy", "select": true})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
// The name clashing with the built-in Hard Round is made unique.
|
||||
assert_eq!(r["imported"], json!(["Hard Round 2", "9 px Round"]));
|
||||
assert_eq!(s.tools.presets.len(), before + 2);
|
||||
assert!(s.tools.presets.iter().filter(|p| p.group == "Legacy").all(|p| !p.builtin));
|
||||
assert!(matches!(s.tools.brush.tip, photocraft_paint::TipShape::Sampled(_)));
|
||||
// Re-import replaces the group.
|
||||
s.execute("brush.presets.importAbr", json!({"data": data, "group": "Legacy"})).unwrap();
|
||||
assert_eq!(s.tools.presets.len(), before + 2);
|
||||
// Imported presets paint.
|
||||
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[5, 30], [60, 30]], "preset": "9 px Round"})).unwrap();
|
||||
// From a file on disk, the group defaults to the file name.
|
||||
let dir = std::env::temp_dir().join(format!("pc-abr-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("My Set.abr");
|
||||
let preset = Descriptor::new("brushPreset")
|
||||
.with("Nm ", DV::Text(UnicodeString::new_nul("Speckle")))
|
||||
.with("Brsh", DV::Descriptor(Descriptor::new("sampledBrush").with("sampledData", DV::Text(UnicodeString::new_nul("$s")))));
|
||||
let tip = AbrSample { id: "$s".into(), width: 5, height: 5, depth: 16, data: vec![0x80; 50] };
|
||||
std::fs::write(&path, write_v6(2, &[tip], &[], &[preset], true).unwrap()).unwrap();
|
||||
let r = s.execute("brush.presets.importAbr", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["group"].as_str(), r["imported"][0].as_str(), r["version"].as_u64()), (Some("My Set"), Some("Speckle"), Some(6)));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_fail_gracefully() {
|
||||
let mut s = Session::new();
|
||||
let n = s.tools.presets.len();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"path": ""}),
|
||||
json!({"path": "/nonexistent/brushes.abr"}),
|
||||
json!({"data": 42}),
|
||||
json!({"data": "%%%not base64"}),
|
||||
json!({"data": ""}),
|
||||
json!({"data": "AAAA"}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(b"8BPS\0\x01junk")}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(&abr_v2()), "group": 5}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(&abr_v2()), "group": " "}),
|
||||
json!({"data": photocraft_paint::tile::b64_encode(&abr_v2()[..20])}),
|
||||
] {
|
||||
assert!(s.execute("brush.presets.importAbr", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.tools.presets.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grd_import_adds_a_gradient_group() {
|
||||
use crate::presets::gradients::StopColor;
|
||||
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write};
|
||||
let grads = vec![
|
||||
GrdGradient {
|
||||
name: "Ember".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
|
||||
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Foreground },
|
||||
],
|
||||
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.0, 0.5)],
|
||||
},
|
||||
GrdGradient { name: "Static".into(), noise: true, ..Default::default() },
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let data = photocraft_paint::tile::b64_encode(&write(&grads));
|
||||
let r = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Fire"})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Ember"]));
|
||||
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1));
|
||||
let g = s.presets.gradients.iter().find(|g| g.name == "Fire").unwrap();
|
||||
assert_eq!(g.items[0].stops, vec![(0.0, StopColor::Foreground), (1.0, StopColor::Rgb([1.0, 0.0, 0.0]))]);
|
||||
assert_eq!(g.items[0].opacity, vec![(0.0, 1.0), (1.0, 0.0)]);
|
||||
// Re-import replaces the group.
|
||||
let n = s.presets.gradients.len();
|
||||
s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Fire"})).unwrap();
|
||||
assert_eq!(s.presets.gradients.len(), n);
|
||||
for p in [json!({}), json!({"data": "@@"}), json!({"data": photocraft_paint::tile::b64_encode(b"8BGR\0\x03")}), json!({"path": "/nope.grd"})] {
|
||||
assert!(s.execute("gradient.presets.importGrd", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
let only_noise = photocraft_paint::tile::b64_encode(&write(&grads[1..]));
|
||||
assert!(s.execute("gradient.presets.importGrd", json!({"data": only_noise})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_conversions() {
|
||||
assert_eq!(hsb_to_rgb([120.0, 1.0, 1.0]), [0.0, 1.0, 0.0]);
|
||||
let w = lab_to_rgb([100.0, 0.0, 0.0]);
|
||||
assert!(w.iter().all(|v| (v - 1.0).abs() < 0.01), "{w:?}");
|
||||
let k = lab_to_rgb([0.0, 0.0, 0.0]);
|
||||
assert!(k.iter().all(|v| *v < 0.01), "{k:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names() {
|
||||
let taken = vec!["A".to_string(), "a 2".to_string()];
|
||||
assert_eq!(unique("a", &taken), "a 3");
|
||||
assert_eq!(unique("B", &taken), "B");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ photocraft-psd = { workspace = true }
|
||||
photocraft-codecs = { workspace = true }
|
||||
photocraft-raw = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-paint = { workspace = true }
|
||||
photocraft-format = { path = "../format" }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Brush import and painting benchmark: parse + map a synthetic 100-brush ABR v6 file (256 px
|
||||
//! RLE-compressed tips, full dynamics descriptors), then paint a 5000-dab stroke with an imported
|
||||
//! brush on a 6000×6000 RGBA8 layer.
|
||||
//!
|
||||
//! `cargo run --release -p photocraft-io --example bench_abr`
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_paint::{StrokePoint, render_stroke};
|
||||
use photocraft_psd::abr::{AbrSample, write_v6};
|
||||
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn prc(v: f64) -> Value {
|
||||
Value::UnitFloat { unit: *b"#Prc", value: v }
|
||||
}
|
||||
|
||||
fn var(control: i32, jitter: f64) -> Value {
|
||||
Value::Descriptor(Descriptor::new("brVr").with("bVTy", Value::Integer(control)).with("jitter", prc(jitter)))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let n = 256u32;
|
||||
let samples: Vec<AbrSample> = (0..100)
|
||||
.map(|i| {
|
||||
let data = (0..n * n)
|
||||
.map(|k| {
|
||||
let (x, y) = ((k % n) as f32 - 128.0, (k / n) as f32 - 128.0);
|
||||
let r = (x * x + y * y).sqrt() / 128.0;
|
||||
(((1.0 - r) * 255.0).clamp(0.0, 255.0) as u8) & if (k + i) % 7 == 0 { 0x7f } else { 0xff }
|
||||
})
|
||||
.collect();
|
||||
AbrSample { id: format!("$tip-{i}"), width: n, height: n, depth: 8, data }
|
||||
})
|
||||
.collect();
|
||||
let presets: Vec<Descriptor> = (0..100)
|
||||
.map(|i| {
|
||||
Descriptor::new("brushPreset")
|
||||
.with("Nm ", Value::Text(UnicodeString::new_nul(&format!("Brush {i}"))))
|
||||
.with(
|
||||
"Brsh",
|
||||
Value::Descriptor(
|
||||
Descriptor::new("sampledBrush")
|
||||
.with("Dmtr", Value::UnitFloat { unit: *b"#Pxl", value: 60.0 })
|
||||
.with("Spcn", prc(25.0))
|
||||
.with("sampledData", Value::Text(UnicodeString::new_nul(&format!("$tip-{i}")))),
|
||||
),
|
||||
)
|
||||
.with("useTipDynamics", Value::Boolean(true))
|
||||
.with("szVr", var(2, 20.0))
|
||||
.with("angleDynamics", var(0, 100.0))
|
||||
.with("useScatter", Value::Boolean(true))
|
||||
.with("scatterDynamics", var(0, 120.0))
|
||||
.with("Cnt ", Value::Integer(2))
|
||||
.with("usePaintDynamics", Value::Boolean(true))
|
||||
.with("opVr", var(2, 0.0))
|
||||
})
|
||||
.collect();
|
||||
let bytes = write_v6(2, &samples, &[], &presets, true).unwrap();
|
||||
// `--write <path>`: also save a small showcase file (procedural tips) for UI snapshots.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(path) = args.iter().position(|a| a == "--write").and_then(|i| args.get(i + 1)) {
|
||||
use photocraft_paint::procedural::*;
|
||||
let tips = [("Leafy", leaf_tip(96)), ("Charcoal Stick", charcoal_tip(96, 3)), ("Sea Sponge", sponge_tip(96, 5)), ("Star Burst", star_tip(96))];
|
||||
let samples: Vec<AbrSample> = tips
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, t))| AbrSample {
|
||||
id: format!("$show-{i}"),
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
depth: 16,
|
||||
data: t.data.iter().flat_map(|v| v.to_be_bytes()).collect(),
|
||||
})
|
||||
.collect();
|
||||
let presets: Vec<Descriptor> = tips
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, _))| {
|
||||
let mut d = presets[i].clone();
|
||||
d.items.retain(|(k, _)| !k.is("Nm ") && !k.is("Brsh"));
|
||||
d.with("Nm ", Value::Text(UnicodeString::new_nul(name))).with(
|
||||
"Brsh",
|
||||
Value::Descriptor(
|
||||
Descriptor::new("sampledBrush")
|
||||
.with("Dmtr", Value::UnitFloat { unit: *b"#Pxl", value: 50.0 })
|
||||
.with("Spcn", prc(if i == 1 { 10.0 } else { 45.0 }))
|
||||
.with("sampledData", Value::Text(UnicodeString::new_nul(&format!("$show-{i}")))),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(path, write_v6(2, &samples, &[], &presets, true).unwrap()).unwrap();
|
||||
println!("wrote {path}");
|
||||
}
|
||||
let mut best = f64::MAX;
|
||||
let mut imp = None;
|
||||
for _ in 0..5 {
|
||||
let t = Instant::now();
|
||||
imp = Some(photocraft_io::abr_map::read_abr(&bytes, "Bench").unwrap());
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let imp = imp.unwrap();
|
||||
println!("ABR import: {} brushes, {:.1} MB file, best of 5: {best:.1} ms", imp.presets.len(), bytes.len() as f64 / 1e6);
|
||||
|
||||
// A long pressure-varied stroke with the first imported brush (size, angle, scatter, opacity dynamics).
|
||||
let mut brush = imp.presets[0].brush.clone();
|
||||
brush.seed = 1;
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let size = 6000.0;
|
||||
let pts: Vec<StrokePoint> = (0..=2000)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 2000.0;
|
||||
StrokePoint::new(200.0 + t * (size - 400.0), size / 2.0 + (t * 24.0).sin() * 1500.0, (0.3 + 0.7 * (t * 25.0).sin().abs()) as f32)
|
||||
})
|
||||
.collect();
|
||||
let dabs = photocraft_paint::dabs(&photocraft_paint::Stroke { brush: brush.clone(), points: pts.clone() }).len();
|
||||
let t = Instant::now();
|
||||
render_stroke(&mut s, &brush, &pts, None, false, 1.0);
|
||||
println!("Stroke: {dabs} primary dabs (scatter count 2), {:.1} ms", t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
//! Photoshop brushes (`.abr`) → brush presets.
|
||||
//!
|
||||
//! [`photocraft_psd::abr`] reads the file; this module maps its tips and `brushPreset`
|
||||
//! descriptors onto [`BrushSettings`] (Brush Tip Shape, Shape Dynamics, Scattering, Texture,
|
||||
//! Dual Brush, Color Dynamics, Transfer, Brush Pose, Noise, Wet Edges, Build-up, Smoothing and
|
||||
//! the captured tool options). Settings without an equivalent are listed in
|
||||
//! [`AbrImport::warnings`] instead of being dropped silently. Descriptor key names follow the
|
||||
//! publicly documented ABR v6+ layout.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use photocraft_paint::{
|
||||
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
|
||||
};
|
||||
use photocraft_psd::abr::{self, AbrFile, AbrSample, LegacyTip};
|
||||
use photocraft_psd::descriptor::{Descriptor, Value};
|
||||
use photocraft_psd::patterns::PsdPattern;
|
||||
|
||||
/// Largest sampled tip edge kept as is; bigger tips are downsampled to it.
|
||||
pub const MAX_TIP_EDGE: u32 = 2500;
|
||||
/// Largest texture pattern edge kept as is; bigger patterns are downsampled to it.
|
||||
pub const MAX_PATTERN_EDGE: u32 = 1024;
|
||||
|
||||
/// The presets read from one file plus everything that could not be mapped.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AbrImport {
|
||||
pub presets: Vec<BrushPreset>,
|
||||
pub warnings: Vec<String>,
|
||||
/// File version (1, 2, 6…).
|
||||
pub version: u16,
|
||||
}
|
||||
|
||||
/// Parse an `.abr` file into presets in group `group` (usually the file name).
|
||||
pub fn read_abr(bytes: &[u8], group: &str) -> Result<AbrImport, String> {
|
||||
let f = abr::parse(bytes).map_err(|e| format!("not a readable Photoshop brush file: {e}"))?;
|
||||
Ok(map_file(&f, group))
|
||||
}
|
||||
|
||||
/// Map a parsed file.
|
||||
pub fn map_file(f: &AbrFile, group: &str) -> AbrImport {
|
||||
let mut out = AbrImport { version: f.version, warnings: f.warnings.clone(), ..Default::default() };
|
||||
let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new() };
|
||||
if !f.legacy.is_empty() {
|
||||
for (i, b) in f.legacy.iter().enumerate() {
|
||||
let spacing = if b.spacing == 0 { 0.25 } else { f32::from(b.spacing) / 100.0 };
|
||||
let base = BrushSettings { pressure_size: false, spacing, ..Default::default() };
|
||||
let (brush, default_name) = match &b.tip {
|
||||
LegacyTip::Computed { diameter, hardness, angle, roundness } => (
|
||||
BrushSettings {
|
||||
size: f32::from((*diameter).max(1)),
|
||||
hardness: (f32::from(*hardness) / 100.0).clamp(0.0, 1.0),
|
||||
angle: f32::from(*angle),
|
||||
roundness: (f32::from(*roundness) / 100.0).clamp(0.01, 1.0),
|
||||
..base
|
||||
},
|
||||
format!("{} px Round", diameter),
|
||||
),
|
||||
LegacyTip::Sampled(s) => {
|
||||
let (tip, size) = m.tip(s);
|
||||
(BrushSettings { size, tip, aliased: !b.anti_alias, ..base }, format!("Sampled Brush {}", i + 1))
|
||||
}
|
||||
};
|
||||
let name = if b.name.trim().is_empty() { default_name } else { b.name.trim().to_string() };
|
||||
out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string() });
|
||||
}
|
||||
} else if f.presets.is_empty() {
|
||||
// Tips without a settings section (early v6 files): one preset per tip.
|
||||
for (i, s) in f.samples.iter().enumerate() {
|
||||
let (tip, size) = m.tip(s);
|
||||
let brush = BrushSettings { pressure_size: false, spacing: 0.25, size, tip, ..Default::default() };
|
||||
out.presets.push(BrushPreset { name: format!("Sampled Brush {}", i + 1), brush, builtin: false, group: group.to_string() });
|
||||
}
|
||||
} else {
|
||||
for (i, d) in f.presets.iter().enumerate() {
|
||||
let name = text(d, "Nm ").filter(|n| !n.trim().is_empty()).map(|n| n.trim().to_string()).unwrap_or_else(|| format!("Brush {}", i + 1));
|
||||
match m.preset(d) {
|
||||
Some(brush) => out.presets.push(BrushPreset { name, brush, builtin: false, group: group.to_string() }),
|
||||
None => {
|
||||
m.warnings.insert(format!("\"{name}\": its sampled tip is missing from the file; skipped"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.warnings.extend(m.warnings);
|
||||
if !m.unknown.is_empty() {
|
||||
out.warnings.push(format!("settings without a PhotoCraft equivalent were ignored: {}", m.unknown.into_iter().collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ descriptor helpers
|
||||
|
||||
fn get<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Value> {
|
||||
d.get(k)
|
||||
}
|
||||
fn num(d: &Descriptor, k: &str) -> Option<f64> {
|
||||
match get(d, k)? {
|
||||
Value::Double(v) => Some(*v),
|
||||
Value::UnitFloat { value, .. } => Some(*value),
|
||||
Value::Integer(v) => Some(f64::from(*v)),
|
||||
Value::LargeInteger(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
/// A percentage as a 0..1 fraction.
|
||||
fn pct(d: &Descriptor, k: &str) -> Option<f32> {
|
||||
num(d, k).map(|v| (v / 100.0) as f32)
|
||||
}
|
||||
fn boolean(d: &Descriptor, k: &str) -> Option<bool> {
|
||||
match get(d, k)? {
|
||||
Value::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn text(d: &Descriptor, k: &str) -> Option<String> {
|
||||
match get(d, k)? {
|
||||
Value::Text(t) => Some(t.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> {
|
||||
match get(d, k)? {
|
||||
Value::Descriptor(o) | Value::GlobalObject(o) => Some(o),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn enum_value(d: &Descriptor, k: &str) -> Option<String> {
|
||||
match get(d, k)? {
|
||||
Value::Enumerated { value, .. } => Some(String::from_utf8_lossy(value.as_bytes()).to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_mode(code: Option<String>) -> Option<MaskMode> {
|
||||
Some(match code?.as_str() {
|
||||
"Mltp" => MaskMode::Multiply,
|
||||
"Sbtr" => MaskMode::Subtract,
|
||||
"Drkn" => MaskMode::Darken,
|
||||
"Ovrl" => MaskMode::Overlay,
|
||||
"CDdg" => MaskMode::ColorDodge,
|
||||
"CBrn" => MaskMode::ColorBurn,
|
||||
"linearBurn" => MaskMode::LinearBurn,
|
||||
"hardMix" => MaskMode::HardMix,
|
||||
"linearHeight" => MaskMode::LinearHeight,
|
||||
"height" | "Hght" => MaskMode::Height,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Preset-level keys this mapper understands (anything else is reported as unmapped).
|
||||
const KNOWN: &[&str] = &[
|
||||
"Nm ",
|
||||
"Brsh",
|
||||
"useTipDynamics",
|
||||
"flipX",
|
||||
"flipY",
|
||||
"brushProjection",
|
||||
"minimumDiameter",
|
||||
"minimumRoundness",
|
||||
"tiltScale",
|
||||
"szVr",
|
||||
"angleDynamics",
|
||||
"roundnessDynamics",
|
||||
"useScatter",
|
||||
"Spcn",
|
||||
"Cnt ",
|
||||
"bothAxes",
|
||||
"countDynamics",
|
||||
"scatterDynamics",
|
||||
"dualBrush",
|
||||
"brushGroup",
|
||||
"useTexture",
|
||||
"TxtC",
|
||||
"interpretation",
|
||||
"textureBlendMode",
|
||||
"textureDepth",
|
||||
"minimumDepth",
|
||||
"textureDepthDynamics",
|
||||
"Txtr",
|
||||
"textureScale",
|
||||
"InvT",
|
||||
"protectTexture",
|
||||
"textureBrightness",
|
||||
"textureContrast",
|
||||
"usePaintDynamics",
|
||||
"prVr",
|
||||
"opVr",
|
||||
"wtVr",
|
||||
"mxVr",
|
||||
"useColorDynamics",
|
||||
"clVr",
|
||||
"H ",
|
||||
"Strt",
|
||||
"Brgh",
|
||||
"purity",
|
||||
"colorDynamicsPerTip",
|
||||
"Wtdg",
|
||||
"Nose",
|
||||
"Rpt ",
|
||||
"useLegacy",
|
||||
"useBrushPose",
|
||||
"overridePoseAngle",
|
||||
"overridePoseTiltX",
|
||||
"overridePoseTiltY",
|
||||
"overridePosePressure",
|
||||
"brushPosePressure",
|
||||
"brushPoseTiltX",
|
||||
"brushPoseTiltY",
|
||||
"brushPoseAngle",
|
||||
"toolOptions",
|
||||
];
|
||||
|
||||
struct Mapper<'a> {
|
||||
file: &'a AbrFile,
|
||||
warnings: BTreeSet<String>,
|
||||
unknown: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Mapper<'_> {
|
||||
/// A sampled tip (downsampled to [`MAX_TIP_EDGE`]) and its size (larger side).
|
||||
fn tip(&mut self, s: &AbrSample) -> (TipShape, f32) {
|
||||
let v = s.to_unit();
|
||||
let (w, h, v) = fit(s.width, s.height, v, MAX_TIP_EDGE);
|
||||
if (w, h) != (s.width, s.height) {
|
||||
self.warnings.insert(format!("tips larger than {MAX_TIP_EDGE} px were downsampled"));
|
||||
}
|
||||
let data = v.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect();
|
||||
(TipShape::Sampled(GrayTile { width: w, height: h, data }), s.width.max(s.height) as f32)
|
||||
}
|
||||
|
||||
fn dynamic(&mut self, d: Option<&Descriptor>) -> Dynamic {
|
||||
let Some(d) = d else { return Dynamic::default() };
|
||||
let control = match num(d, "bVTy").unwrap_or(0.0) as i64 {
|
||||
1 => Control::Fade,
|
||||
2 => Control::PenPressure,
|
||||
3 => Control::PenTilt,
|
||||
4 => Control::StylusWheel,
|
||||
5 => Control::InitialDirection,
|
||||
6 => Control::Direction,
|
||||
7 => {
|
||||
self.warnings.insert("\"Initial Rotation\" control mapped to Rotation".into());
|
||||
Control::Rotation
|
||||
}
|
||||
8 => Control::Rotation,
|
||||
_ => Control::Off,
|
||||
};
|
||||
Dynamic {
|
||||
jitter: pct(d, "jitter").unwrap_or(0.0).max(0.0),
|
||||
control,
|
||||
fade_steps: num(d, "fStp").map_or(25, |v| v.clamp(1.0, 9999.0) as u32),
|
||||
minimum: pct(d, "Mnm ").unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tip-shape fields from a `computedBrush` / `sampledBrush` object. `None` when the sampled
|
||||
/// tip it references is missing.
|
||||
fn tip_shape(&mut self, t: &Descriptor) -> Option<(TipShape, Option<f32>)> {
|
||||
let sampled = t.class_id.is("sampledBrush") || get(t, "sampledData").is_some();
|
||||
if !sampled {
|
||||
return Some((TipShape::Round, None));
|
||||
}
|
||||
let id = text(t, "sampledData").unwrap_or_default();
|
||||
let s = self.file.samples.iter().find(|s| s.id == id).or_else(|| self.file.samples.first().filter(|_| self.file.samples.len() == 1))?;
|
||||
let (tip, size) = self.tip(s);
|
||||
Some((tip, Some(size)))
|
||||
}
|
||||
|
||||
fn pattern(&mut self, t: &Descriptor) -> Pattern {
|
||||
let id = text(t, "Idnt").unwrap_or_default();
|
||||
let name = text(t, "Nm ").unwrap_or_default();
|
||||
let found =
|
||||
self.file.patterns.iter().find(|p| !id.is_empty() && p.id == id).or_else(|| self.file.patterns.iter().find(|p| !name.is_empty() && p.name == name));
|
||||
match found.and_then(pattern_tile) {
|
||||
Some(tile) => Pattern::Tile(tile),
|
||||
None => {
|
||||
let label = if name.is_empty() { id } else { name };
|
||||
self.warnings.insert(format!("texture pattern \"{label}\" is not embedded in the file; a procedural paper texture stands in"));
|
||||
Pattern::Procedural { style: PatternStyle::Paper, size: 128, seed: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn preset(&mut self, d: &Descriptor) -> Option<BrushSettings> {
|
||||
for (k, _) in &d.items {
|
||||
let k = String::from_utf8_lossy(k.as_bytes()).to_string();
|
||||
if !KNOWN.contains(&k.as_str()) {
|
||||
self.unknown.insert(k);
|
||||
}
|
||||
}
|
||||
let mut b = BrushSettings { pressure_size: false, spacing: 0.25, ..Default::default() };
|
||||
if let Some(t) = obj(d, "Brsh") {
|
||||
let (tip, sampled_size) = self.tip_shape(t)?;
|
||||
b.tip = tip;
|
||||
b.size = num(t, "Dmtr").map(|v| v as f32).or(sampled_size).unwrap_or(b.size).clamp(1.0, 5000.0);
|
||||
if let Some(v) = pct(t, "Hrdn") {
|
||||
b.hardness = v.clamp(0.0, 1.0);
|
||||
}
|
||||
b.angle = num(t, "Angl").unwrap_or(0.0) as f32;
|
||||
b.roundness = pct(t, "Rndn").unwrap_or(1.0).clamp(0.01, 1.0);
|
||||
// `Intr` off = spacing unchecked (Photoshop spaces by pointer speed): keep it tight.
|
||||
b.spacing = if boolean(t, "Intr") == Some(false) { 0.05 } else { pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0) };
|
||||
b.flip_x = boolean(t, "flipX").unwrap_or(false);
|
||||
b.flip_y = boolean(t, "flipY").unwrap_or(false);
|
||||
}
|
||||
// Shape Dynamics.
|
||||
if boolean(d, "useTipDynamics") == Some(true) {
|
||||
let mut size = self.dynamic(obj(d, "szVr"));
|
||||
size.minimum = pct(d, "minimumDiameter").unwrap_or(size.minimum).clamp(0.0, 1.0);
|
||||
let mut roundness = self.dynamic(obj(d, "roundnessDynamics"));
|
||||
roundness.minimum = pct(d, "minimumRoundness").unwrap_or(roundness.minimum).clamp(0.0, 1.0);
|
||||
b.shape_dynamics = ShapeDynamics {
|
||||
enabled: true,
|
||||
size,
|
||||
angle: self.dynamic(obj(d, "angleDynamics")),
|
||||
roundness,
|
||||
flip_x_jitter: boolean(d, "flipX").unwrap_or(false),
|
||||
flip_y_jitter: boolean(d, "flipY").unwrap_or(false),
|
||||
};
|
||||
if pct(d, "tiltScale").is_some_and(|v| v > 0.0) {
|
||||
self.warnings.insert("Tilt Scale (Shape Dynamics) is not supported".into());
|
||||
}
|
||||
if boolean(d, "brushProjection") == Some(true) {
|
||||
self.warnings.insert("Brush Projection is not supported".into());
|
||||
}
|
||||
}
|
||||
// Scattering.
|
||||
if boolean(d, "useScatter") == Some(true) {
|
||||
b.scattering.enabled = true;
|
||||
b.scattering.scatter = self.dynamic(obj(d, "scatterDynamics"));
|
||||
b.scattering.both_axes = boolean(d, "bothAxes").unwrap_or(false);
|
||||
b.scattering.count = num(d, "Cnt ").map_or(1, |v| v.clamp(1.0, 16.0) as u32);
|
||||
b.scattering.count_jitter = self.dynamic(obj(d, "countDynamics"));
|
||||
}
|
||||
// Texture.
|
||||
if boolean(d, "useTexture") == Some(true) {
|
||||
let t = &mut b.texture;
|
||||
t.enabled = true;
|
||||
t.each_tip = boolean(d, "TxtC").unwrap_or(false);
|
||||
t.invert = boolean(d, "InvT").unwrap_or(false);
|
||||
t.scale = pct(d, "textureScale").unwrap_or(1.0).clamp(0.01, 10.0);
|
||||
t.brightness = (num(d, "textureBrightness").unwrap_or(0.0) / 150.0).clamp(-1.0, 1.0) as f32;
|
||||
t.contrast = (num(d, "textureContrast").unwrap_or(0.0) / 50.0).clamp(-1.0, 2.0) as f32;
|
||||
t.mode = mask_mode(enum_value(d, "textureBlendMode")).unwrap_or(MaskMode::Multiply);
|
||||
t.depth = pct(d, "textureDepth").unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let mut dj = self.dynamic(obj(d, "textureDepthDynamics"));
|
||||
dj.minimum = pct(d, "minimumDepth").unwrap_or(dj.minimum).clamp(0.0, 1.0);
|
||||
t.depth_jitter = dj;
|
||||
if let Some(p) = obj(d, "Txtr") {
|
||||
b.texture.pattern = self.pattern(p);
|
||||
}
|
||||
}
|
||||
b.protect_texture = boolean(d, "protectTexture").unwrap_or(false);
|
||||
// Dual Brush.
|
||||
if let Some(db) = obj(d, "dualBrush").filter(|db| boolean(db, "useDualBrush") == Some(true)) {
|
||||
let mut dual = DualBrush { enabled: true, ..Default::default() };
|
||||
if let Some(t) = obj(db, "Brsh") {
|
||||
match self.tip_shape(t) {
|
||||
Some((tip, sampled_size)) => {
|
||||
dual.tip = tip;
|
||||
dual.size = num(t, "Dmtr").map(|v| v as f32).or(sampled_size).unwrap_or(dual.size).clamp(1.0, 5000.0);
|
||||
}
|
||||
None => {
|
||||
self.warnings.insert("a dual brush tip is missing from the file; a round tip stands in".into());
|
||||
}
|
||||
}
|
||||
dual.hardness = pct(t, "Hrdn").unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
dual.roundness = pct(t, "Rndn").unwrap_or(1.0).clamp(0.01, 1.0);
|
||||
dual.angle = num(t, "Angl").unwrap_or(0.0) as f32;
|
||||
dual.spacing = pct(t, "Spcn").unwrap_or(dual.spacing).clamp(0.01, 10.0);
|
||||
}
|
||||
dual.mode = mask_mode(enum_value(db, "BlnM")).unwrap_or(MaskMode::Multiply);
|
||||
dual.flip = boolean(db, "Flip").unwrap_or(false);
|
||||
if let Some(v) = pct(db, "Spcn") {
|
||||
dual.spacing = v.clamp(0.01, 10.0);
|
||||
}
|
||||
dual.scatter = self.dynamic(obj(db, "scatterDynamics")).jitter;
|
||||
dual.both_axes = boolean(db, "bothAxes").unwrap_or(false);
|
||||
dual.count = num(db, "Cnt ").map_or(1, |v| v.clamp(1.0, 16.0) as u32);
|
||||
b.dual_brush = dual;
|
||||
}
|
||||
// Color Dynamics.
|
||||
if boolean(d, "useColorDynamics") == Some(true) {
|
||||
let c = &mut b.color_dynamics;
|
||||
c.enabled = true;
|
||||
c.per_tip = boolean(d, "colorDynamicsPerTip").unwrap_or(true);
|
||||
c.hue_jitter = pct(d, "H ").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
c.saturation_jitter = pct(d, "Strt").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
c.brightness_jitter = pct(d, "Brgh").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
c.purity = pct(d, "purity").unwrap_or(0.0).clamp(-1.0, 1.0);
|
||||
let fg = self.dynamic(obj(d, "clVr"));
|
||||
b.color_dynamics.fg_bg = fg;
|
||||
}
|
||||
// Transfer.
|
||||
if boolean(d, "usePaintDynamics") == Some(true) {
|
||||
b.transfer = Transfer { enabled: true, opacity: self.dynamic(obj(d, "opVr")), flow: self.dynamic(obj(d, "prVr")) };
|
||||
for (k, what) in [("wtVr", "Wetness"), ("mxVr", "Mix")] {
|
||||
if self.dynamic(obj(d, k)).is_active() {
|
||||
self.warnings.insert(format!("Transfer › {what} jitter (Mixer Brush) is not supported"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Brush Pose (Photoshop's tilt is ±100 %; ours is ±90°).
|
||||
if boolean(d, "useBrushPose") == Some(true) {
|
||||
b.pose = Pose {
|
||||
enabled: true,
|
||||
tilt_x: (num(d, "brushPoseTiltX").unwrap_or(0.0) * 0.9).clamp(-90.0, 90.0) as f32,
|
||||
tilt_y: (num(d, "brushPoseTiltY").unwrap_or(0.0) * 0.9).clamp(-90.0, 90.0) as f32,
|
||||
rotation: num(d, "brushPoseAngle").unwrap_or(0.0).rem_euclid(360.0) as f32,
|
||||
pressure: pct(d, "brushPosePressure").unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
override_tilt: boolean(d, "overridePoseTiltX").unwrap_or(false) || boolean(d, "overridePoseTiltY").unwrap_or(false),
|
||||
override_rotation: boolean(d, "overridePoseAngle").unwrap_or(false),
|
||||
override_pressure: boolean(d, "overridePosePressure").unwrap_or(false),
|
||||
};
|
||||
}
|
||||
b.noise = boolean(d, "Nose").unwrap_or(false);
|
||||
b.wet_edges = boolean(d, "Wtdg").unwrap_or(false);
|
||||
b.build_up = boolean(d, "Rpt ").unwrap_or(false);
|
||||
// Tool options captured with the preset ("Include tool settings").
|
||||
if let Some(to) = obj(d, "toolOptions") {
|
||||
if let Some(v) = pct(to, "Opct") {
|
||||
b.opacity = v.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(v) = pct(to, "flow") {
|
||||
b.flow = v.clamp(0.0, 1.0);
|
||||
}
|
||||
b.pressure_size = boolean(to, "usePressureOverridesSize").unwrap_or(false);
|
||||
b.pressure_opacity = boolean(to, "usePressureOverridesOpacity").unwrap_or(false);
|
||||
if boolean(to, "smoothing").unwrap_or(true)
|
||||
&& let Some(v) = pct(to, "smoothingValue")
|
||||
{
|
||||
b.smoothing.amount = v.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(v) = boolean(to, "smoothingRadiusMode") {
|
||||
b.smoothing.pulled_string = v;
|
||||
}
|
||||
if let Some(v) = boolean(to, "smoothingCatchup") {
|
||||
b.smoothing.catch_up = v;
|
||||
}
|
||||
if let Some(v) = boolean(to, "smoothingCatchupAtEnd") {
|
||||
b.smoothing.catch_up_on_end = v;
|
||||
}
|
||||
if let Some(v) = boolean(to, "smoothingZoomCompensation") {
|
||||
b.smoothing.adjust_for_zoom = v;
|
||||
}
|
||||
if mask_blend_is_set(to) {
|
||||
self.warnings.insert("tool blend modes stored with presets are not applied".into());
|
||||
}
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_blend_is_set(to: &Descriptor) -> bool {
|
||||
enum_value(to, "Md ").is_some_and(|m| m != "Nrml")
|
||||
}
|
||||
|
||||
/// Box-downsample a `w × h` plane so neither side exceeds `max`.
|
||||
fn fit(w: u32, h: u32, v: Vec<f32>, max: u32) -> (u32, u32, Vec<f32>) {
|
||||
if w <= max && h <= max {
|
||||
return (w, h, v);
|
||||
}
|
||||
let k = (w.max(h) as f32 / max as f32).max(1.0);
|
||||
let (nw, nh) = (((w as f32 / k).round() as u32).clamp(1, max), ((h as f32 / k).round() as u32).clamp(1, max));
|
||||
let mut out = vec![0.0f32; nw as usize * nh as usize];
|
||||
for y in 0..nh {
|
||||
let (y0, y1) = ((y * h / nh) as usize, (((y + 1) * h / nh) as usize).max((y * h / nh) as usize + 1).min(h as usize));
|
||||
for x in 0..nw {
|
||||
let (x0, x1) = ((x * w / nw) as usize, (((x + 1) * w / nw) as usize).max((x * w / nw) as usize + 1).min(w as usize));
|
||||
let (mut sum, mut n) = (0.0, 0usize);
|
||||
for yy in y0..y1 {
|
||||
for xx in x0..x1 {
|
||||
if let Some(s) = v.get(yy * w as usize + xx) {
|
||||
sum += s;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out[(y * nw + x) as usize] = if n > 0 { sum / n as f32 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
(nw, nh, out)
|
||||
}
|
||||
|
||||
/// A pattern as a grayscale texture tile (luminance; transparent areas count as white).
|
||||
fn pattern_tile(p: &PsdPattern) -> Option<GrayTile> {
|
||||
let (w, h) = (p.width, p.height);
|
||||
let n = w as usize * h as usize;
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let plane = |c: usize| -> Option<Vec<f32>> {
|
||||
let ch = p.channels.get(c)?;
|
||||
Some(match p.depth {
|
||||
8 => ch.iter().take(n).map(|&b| f32::from(b) / 255.0).collect(),
|
||||
16 => ch.as_chunks::<2>().0.iter().take(n).map(|b| f32::from(u16::from_be_bytes(*b)) / 65535.0).collect(),
|
||||
32 => ch.as_chunks::<4>().0.iter().take(n).map(|b| f32::from_be_bytes(*b).clamp(0.0, 1.0)).collect(),
|
||||
_ => return None,
|
||||
})
|
||||
.filter(|v: &Vec<f32>| v.len() == n)
|
||||
};
|
||||
let gray: Vec<f32> = match p.mode {
|
||||
3 => {
|
||||
let (r, g, b) = (plane(0)?, plane(1)?, plane(2)?);
|
||||
(0..n).map(|i| 0.299 * r[i] + 0.587 * g[i] + 0.114 * b[i]).collect()
|
||||
}
|
||||
2 => {
|
||||
let pal = p.palette.as_deref()?;
|
||||
let idx = p.channels.first()?;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let k = usize::from(idx.get(i).copied().unwrap_or(0)) * 3;
|
||||
let c = |o: usize| f32::from(pal.get(k + o).copied().unwrap_or(0)) / 255.0;
|
||||
0.299 * c(0) + 0.587 * c(1) + 0.114 * c(2)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
4 => {
|
||||
// CMYK planes are stored inverted (0 = full ink).
|
||||
let k = plane(3)?;
|
||||
let c = plane(0)?;
|
||||
(0..n).map(|i| (c[i] * k[i]).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
_ => plane(0)?,
|
||||
};
|
||||
let (w, h, gray) = fit(w, h, gray, MAX_PATTERN_EDGE);
|
||||
Some(GrayTile { width: w, height: h, data: gray.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
use photocraft_psd::abr::{LegacyBrush, write_v6, write_v12};
|
||||
use photocraft_psd::descriptor::{Id, UnicodeString};
|
||||
|
||||
fn sample(id: &str, w: u32, h: u32, depth: u16) -> AbrSample {
|
||||
let bpp = usize::from(depth / 8);
|
||||
let data = (0..w as usize * h as usize).flat_map(|i| std::iter::repeat_n(if i % 2 == 0 { 255u8 } else { 0 }, bpp)).collect();
|
||||
AbrSample { id: id.into(), width: w, height: h, depth, data }
|
||||
}
|
||||
|
||||
fn t(s: &str) -> Value {
|
||||
Value::Text(UnicodeString::new_nul(s))
|
||||
}
|
||||
fn u(unit: &[u8; 4], v: f64) -> Value {
|
||||
Value::UnitFloat { unit: *unit, value: v }
|
||||
}
|
||||
fn prc(v: f64) -> Value {
|
||||
u(b"#Prc", v)
|
||||
}
|
||||
fn en(ty: &str, v: &str) -> Value {
|
||||
Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) }
|
||||
}
|
||||
fn var(control: i32, jitter: f64, minimum: f64) -> Value {
|
||||
Value::Descriptor(
|
||||
Descriptor::new("brVr").with("bVTy", Value::Integer(control)).with("fStp", Value::Integer(40)).with("jitter", prc(jitter)).with("Mnm ", prc(minimum)),
|
||||
)
|
||||
}
|
||||
|
||||
fn sampled_tip(id: &str) -> Descriptor {
|
||||
Descriptor::new("sampledBrush")
|
||||
.with("Dmtr", u(b"#Pxl", 64.0))
|
||||
.with("Angl", u(b"#Ang", 30.0))
|
||||
.with("Rndn", prc(80.0))
|
||||
.with("Spcn", prc(40.0))
|
||||
.with("Intr", Value::Boolean(true))
|
||||
.with("flipX", Value::Boolean(true))
|
||||
.with("flipY", Value::Boolean(false))
|
||||
.with("sampledData", t(id))
|
||||
}
|
||||
|
||||
/// A preset touching every section.
|
||||
fn full_preset() -> Descriptor {
|
||||
Descriptor::new("brushPreset")
|
||||
.with("Nm ", t("Kitchen Sink"))
|
||||
.with("Brsh", Value::Descriptor(sampled_tip("$tip-1")))
|
||||
.with("useTipDynamics", Value::Boolean(true))
|
||||
.with("flipX", Value::Boolean(true))
|
||||
.with("flipY", Value::Boolean(false))
|
||||
.with("minimumDiameter", prc(25.0))
|
||||
.with("minimumRoundness", prc(10.0))
|
||||
.with("tiltScale", prc(0.0))
|
||||
.with("szVr", var(2, 20.0, 0.0))
|
||||
.with("angleDynamics", var(6, 10.0, 0.0))
|
||||
.with("roundnessDynamics", var(3, 5.0, 0.0))
|
||||
.with("useScatter", Value::Boolean(true))
|
||||
.with("Cnt ", Value::Integer(3))
|
||||
.with("bothAxes", Value::Boolean(true))
|
||||
.with("countDynamics", var(0, 50.0, 0.0))
|
||||
.with("scatterDynamics", var(1, 250.0, 0.0))
|
||||
.with("useTexture", Value::Boolean(true))
|
||||
.with("TxtC", Value::Boolean(true))
|
||||
.with("InvT", Value::Boolean(true))
|
||||
.with("textureScale", prc(150.0))
|
||||
.with("textureBrightness", Value::Integer(-75))
|
||||
.with("textureContrast", Value::Integer(25))
|
||||
.with("textureBlendMode", en("BlnM", "CBrn"))
|
||||
.with("textureDepth", prc(60.0))
|
||||
.with("minimumDepth", prc(20.0))
|
||||
.with("textureDepthDynamics", var(2, 30.0, 0.0))
|
||||
.with("Txtr", Value::Descriptor(Descriptor::new("Ptrn").with("Nm ", t("Grit")).with("Idnt", t("pat-uuid"))))
|
||||
.with(
|
||||
"dualBrush",
|
||||
Value::Descriptor(
|
||||
Descriptor::new("dualBrush")
|
||||
.with("useDualBrush", Value::Boolean(true))
|
||||
.with("Flip", Value::Boolean(true))
|
||||
.with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 12.0)).with("Hrdn", prc(50.0))))
|
||||
.with("BlnM", en("BlnM", "hardMix"))
|
||||
.with("Spcn", prc(30.0))
|
||||
.with("Cnt ", Value::Integer(2))
|
||||
.with("bothAxes", Value::Boolean(true))
|
||||
.with("scatterDynamics", var(0, 120.0, 0.0)),
|
||||
),
|
||||
)
|
||||
.with("useColorDynamics", Value::Boolean(true))
|
||||
.with("clVr", var(2, 40.0, 0.0))
|
||||
.with("H ", prc(10.0))
|
||||
.with("Strt", prc(20.0))
|
||||
.with("Brgh", prc(30.0))
|
||||
.with("purity", prc(-50.0))
|
||||
.with("colorDynamicsPerTip", Value::Boolean(false))
|
||||
.with("usePaintDynamics", Value::Boolean(true))
|
||||
.with("opVr", var(2, 0.0, 10.0))
|
||||
.with("prVr", var(1, 15.0, 5.0))
|
||||
.with("wtVr", var(0, 0.0, 0.0))
|
||||
.with("useBrushPose", Value::Boolean(true))
|
||||
.with("brushPoseTiltX", Value::Integer(50))
|
||||
.with("brushPoseAngle", Value::Integer(90))
|
||||
.with("overridePoseAngle", Value::Boolean(true))
|
||||
.with("Wtdg", Value::Boolean(true))
|
||||
.with("Nose", Value::Boolean(true))
|
||||
.with("Rpt ", Value::Boolean(true))
|
||||
.with("someFutureKey", Value::Boolean(true))
|
||||
.with(
|
||||
"toolOptions",
|
||||
Value::Descriptor(
|
||||
Descriptor::new("currentToolOptions")
|
||||
.with("Opct", Value::Integer(80))
|
||||
.with("flow", Value::Integer(60))
|
||||
.with("smoothing", Value::Boolean(true))
|
||||
.with("smoothingValue", Value::Integer(40))
|
||||
.with("usePressureOverridesSize", Value::Boolean(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn grit_pattern() -> PsdPattern {
|
||||
PsdPattern {
|
||||
mode: 1,
|
||||
width: 4,
|
||||
height: 2,
|
||||
name: "Grit".into(),
|
||||
id: "pat-uuid".into(),
|
||||
palette: None,
|
||||
depth: 8,
|
||||
channels: vec![vec![0, 64, 128, 255, 255, 128, 64, 0]],
|
||||
alpha: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v6_settings_map_onto_brush_sections() {
|
||||
for sub in [1, 2] {
|
||||
let computed = Descriptor::new("brushPreset")
|
||||
.with("Nm ", t("Round 19"))
|
||||
.with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 19.0)).with("Hrdn", prc(0.0)).with("Spcn", prc(10.0))));
|
||||
let bytes = write_v6(sub, &[sample("$tip-1", 16, 8, 8)], &[grit_pattern()], &[full_preset(), computed], true).unwrap();
|
||||
let imp = read_abr(&bytes, "Test").unwrap();
|
||||
assert_eq!(imp.presets.len(), 2);
|
||||
let p = &imp.presets[0];
|
||||
assert_eq!((p.name.as_str(), p.group.as_str(), p.builtin), ("Kitchen Sink", "Test", false));
|
||||
let b = &p.brush;
|
||||
let TipShape::Sampled(tile) = &b.tip else { panic!("sampled tip expected") };
|
||||
assert_eq!((tile.width, tile.height), (16, 8));
|
||||
assert_eq!((tile.data[0], tile.data[1]), (65535, 0));
|
||||
assert_eq!((b.size, b.angle, b.roundness, b.spacing, b.flip_x, b.flip_y), (64.0, 30.0, 0.8, 0.4, true, false));
|
||||
let sd = &b.shape_dynamics;
|
||||
assert!(sd.enabled && sd.flip_x_jitter && !sd.flip_y_jitter);
|
||||
assert_eq!((sd.size.control, sd.size.jitter, sd.size.minimum), (Control::PenPressure, 0.2, 0.25));
|
||||
assert_eq!((sd.angle.control, sd.roundness.control, sd.roundness.minimum), (Control::Direction, Control::PenTilt, 0.1));
|
||||
let sc = &b.scattering;
|
||||
assert!(sc.enabled && sc.both_axes);
|
||||
assert_eq!((sc.count, sc.scatter.jitter, sc.scatter.control, sc.scatter.fade_steps, sc.count_jitter.jitter), (3, 2.5, Control::Fade, 40, 0.5));
|
||||
let tx = &b.texture;
|
||||
assert!(tx.enabled && tx.each_tip && tx.invert);
|
||||
assert_eq!((tx.scale, tx.brightness, tx.contrast, tx.mode, tx.depth), (1.5, -0.5, 0.5, MaskMode::ColorBurn, 0.6));
|
||||
assert_eq!((tx.depth_jitter.jitter, tx.depth_jitter.minimum), (0.3, 0.2));
|
||||
let Pattern::Tile(pt) = &tx.pattern else { panic!("embedded pattern expected, got {:?}", tx.pattern) };
|
||||
assert_eq!((pt.width, pt.height, pt.data[3]), (4, 2, 65535));
|
||||
let db = &b.dual_brush;
|
||||
assert!(db.enabled && db.flip && db.both_axes && db.tip == TipShape::Round);
|
||||
assert_eq!((db.size, db.hardness, db.mode, db.spacing, db.count, db.scatter), (12.0, 0.5, MaskMode::HardMix, 0.3, 2, 1.2));
|
||||
let cd = &b.color_dynamics;
|
||||
assert!(cd.enabled && !cd.per_tip);
|
||||
assert_eq!((cd.fg_bg.jitter, cd.hue_jitter, cd.saturation_jitter, cd.brightness_jitter, cd.purity), (0.4, 0.1, 0.2, 0.3, -0.5));
|
||||
assert!(b.transfer.enabled);
|
||||
assert_eq!((b.transfer.opacity.control, b.transfer.opacity.minimum), (Control::PenPressure, 0.1));
|
||||
assert_eq!((b.transfer.flow.control, b.transfer.flow.jitter), (Control::Fade, 0.15));
|
||||
assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt);
|
||||
assert_eq!((b.pose.tilt_x, b.pose.rotation), (45.0, 90.0));
|
||||
assert!(b.wet_edges && b.noise && b.build_up);
|
||||
assert_eq!((b.opacity, b.flow, b.smoothing.amount, b.pressure_size), (0.8, 0.6, 0.4, true));
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("someFutureKey")), "{:?}", imp.warnings);
|
||||
// The computed preset.
|
||||
let r = &imp.presets[1].brush;
|
||||
assert_eq!((r.tip.clone(), r.size, r.hardness, r.spacing, r.pressure_size), (TipShape::Round, 19.0, 0.0, 0.1, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pattern_and_tip_are_reported() {
|
||||
let orphan = Descriptor::new("brushPreset").with("Nm ", t("Orphan")).with("Brsh", Value::Descriptor(sampled_tip("$nowhere")));
|
||||
let bytes = write_v6(2, &[sample("$tip-1", 4, 4, 16), sample("$tip-2", 4, 4, 8)], &[], &[full_preset(), orphan], false).unwrap();
|
||||
let imp = read_abr(&bytes, "x").unwrap();
|
||||
assert_eq!(imp.presets.len(), 1);
|
||||
assert!(matches!(imp.presets[0].brush.texture.pattern, Pattern::Procedural { .. }));
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("Grit")), "{:?}", imp.warnings);
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("Orphan")), "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_v2_brushes_become_presets() {
|
||||
let brushes = vec![
|
||||
LegacyBrush { name: String::new(), spacing: 25, anti_alias: true, tip: LegacyTip::Computed { diameter: 13, hardness: 50, angle: 20, roundness: 60 } },
|
||||
LegacyBrush { name: "Dune Grass".into(), spacing: 0, anti_alias: false, tip: LegacyTip::Sampled(sample("", 30, 10, 8)) },
|
||||
];
|
||||
for version in [1, 2] {
|
||||
let imp = read_abr(&write_v12(version, &brushes, true).unwrap(), "Old").unwrap();
|
||||
assert_eq!(imp.version, version);
|
||||
assert_eq!(imp.presets.len(), 2);
|
||||
let c = &imp.presets[0];
|
||||
assert_eq!(c.name, "13 px Round");
|
||||
assert_eq!((c.brush.size, c.brush.hardness, c.brush.angle, c.brush.roundness, c.brush.spacing), (13.0, 0.5, 20.0, 0.6, 0.25));
|
||||
let s = &imp.presets[1];
|
||||
assert_eq!(s.name, if version == 2 { "Dune Grass" } else { "Sampled Brush 2" });
|
||||
assert_eq!((s.brush.size, s.brush.spacing, s.brush.aliased), (30.0, 0.25, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v6_without_settings_lists_tips() {
|
||||
let bytes = write_v6(1, &[sample("$a", 8, 8, 8), sample("$b", 3, 9, 16)], &[], &[], true).unwrap();
|
||||
// An empty `Brsh` list: presets come from the tips.
|
||||
let imp = read_abr(&bytes, "g").unwrap();
|
||||
assert_eq!(imp.presets.iter().map(|p| p.brush.size).collect::<Vec<_>>(), vec![8.0, 9.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_tips_are_downsampled() {
|
||||
let s = AbrSample { id: "$big".into(), width: 3000, height: 1500, depth: 8, data: vec![200; 3000 * 1500] };
|
||||
let bytes = write_v6(2, &[s], &[], &[], true).unwrap();
|
||||
let imp = read_abr(&bytes, "g").unwrap();
|
||||
let TipShape::Sampled(t) = &imp.presets[0].brush.tip else { panic!() };
|
||||
assert_eq!((t.width, t.height), (MAX_TIP_EDGE, 1250));
|
||||
assert_eq!(imp.presets[0].brush.size, 3000.0);
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("downsampled")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error() {
|
||||
assert!(read_abr(b"", "g").is_err());
|
||||
assert!(read_abr(b"8BPS not a brush", "g").is_err());
|
||||
assert!(read_abr(&[0, 6, 0, 2], "g").is_err());
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod abr_map;
|
||||
mod adjust_map;
|
||||
pub mod annotations_map;
|
||||
pub mod blocks;
|
||||
|
||||
@@ -437,4 +437,7 @@ pub struct BrushPreset {
|
||||
/// Shipped with Photocraft (built-ins can be deleted from a session but are regenerated on reset).
|
||||
#[serde(default)]
|
||||
pub builtin: bool,
|
||||
/// Preset group (folder) in the Brushes panel, e.g. "General" or an imported file's name.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub group: String,
|
||||
}
|
||||
+369
-57
@@ -1,40 +1,89 @@
|
||||
//! Built-in brush presets (our own designs; sampled tips and textures are generated procedurally).
|
||||
//!
|
||||
//! Grouped like Photoshop's default Brushes panel folders: General, Dry Media, Wet Media and
|
||||
//! Special Effects. Every preset is plain [`BrushSettings`] data, so it serialises, journals and
|
||||
//! can be edited like any user preset.
|
||||
|
||||
use crate::brush::*;
|
||||
use crate::procedural::{bristle_tip, chalk_tip, spatter_tip};
|
||||
use crate::procedural::{bristle_tip, chalk_tip, charcoal_tip, grass_tip, leaf_tip, rake_tip, spatter_tip, sponge_tip, star_tip};
|
||||
|
||||
fn preset(name: &str, brush: BrushSettings) -> BrushPreset {
|
||||
BrushPreset { name: name.to_string(), brush, builtin: true }
|
||||
/// Preset groups, in panel order.
|
||||
pub const GROUPS: [&str; 4] = ["General", "Dry Media", "Wet Media", "Special Effects"];
|
||||
|
||||
fn preset(group: &str, name: &str, brush: BrushSettings) -> BrushPreset {
|
||||
BrushPreset { name: name.to_string(), brush, builtin: true, group: group.to_string() }
|
||||
}
|
||||
|
||||
/// The built-in preset set, in display order.
|
||||
pub fn builtin() -> Vec<BrushPreset> {
|
||||
let base = BrushSettings { pressure_size: false, spacing: 0.25, ..Default::default() };
|
||||
fn pressure() -> Dynamic {
|
||||
Dynamic::controlled(Control::PenPressure)
|
||||
}
|
||||
|
||||
fn pressure_min(minimum: f32) -> Dynamic {
|
||||
Dynamic { minimum, ..pressure() }
|
||||
}
|
||||
|
||||
fn size_dyn(size: Dynamic) -> ShapeDynamics {
|
||||
ShapeDynamics { enabled: true, size, ..Default::default() }
|
||||
}
|
||||
|
||||
fn transfer(opacity: Dynamic, flow: Dynamic) -> Transfer {
|
||||
Transfer { enabled: true, opacity, flow }
|
||||
}
|
||||
|
||||
fn paper(seed: u32, depth: f32) -> Texture {
|
||||
Texture { enabled: true, pattern: Pattern::Procedural { style: PatternStyle::Paper, size: 128, seed }, depth, ..Default::default() }
|
||||
}
|
||||
|
||||
fn general(base: &BrushSettings) -> Vec<BrushPreset> {
|
||||
let g = |n: &str, b: BrushSettings| preset("General", n, b);
|
||||
vec![
|
||||
preset("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..base.clone() }),
|
||||
preset("Soft Round", BrushSettings { size: 45.0, hardness: 0.0, ..base.clone() }),
|
||||
preset(
|
||||
"Hard Round Pressure Size",
|
||||
BrushSettings {
|
||||
size: 30.0,
|
||||
hardness: 1.0,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic::controlled(Control::PenPressure), ..Default::default() },
|
||||
..base.clone()
|
||||
},
|
||||
g("Hard Round", BrushSettings { size: 30.0, hardness: 1.0, ..base.clone() }),
|
||||
g("Soft Round", BrushSettings { size: 45.0, hardness: 0.0, ..base.clone() }),
|
||||
g("Hard Round Fine", BrushSettings { size: 5.0, hardness: 1.0, spacing: 0.1, ..base.clone() }),
|
||||
g("Soft Round Large", BrushSettings { size: 300.0, hardness: 0.0, spacing: 0.15, ..base.clone() }),
|
||||
g("Hard Round Pressure Size", BrushSettings { size: 30.0, hardness: 1.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }),
|
||||
g("Soft Round Pressure Size", BrushSettings { size: 45.0, hardness: 0.0, shape_dynamics: size_dyn(pressure()), ..base.clone() }),
|
||||
g("Hard Round Pressure Opacity", BrushSettings { size: 30.0, hardness: 1.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }),
|
||||
g("Soft Round Pressure Opacity", BrushSettings { size: 45.0, hardness: 0.0, transfer: transfer(pressure(), Dynamic::default()), ..base.clone() }),
|
||||
g(
|
||||
"Hard Round Pressure Opacity and Flow",
|
||||
BrushSettings { size: 30.0, hardness: 0.9, transfer: transfer(pressure(), pressure_min(0.2)), ..base.clone() },
|
||||
),
|
||||
preset(
|
||||
"Soft Round Pressure Opacity",
|
||||
g("Airbrush Soft", BrushSettings { size: 80.0, hardness: 0.0, flow: 0.1, spacing: 0.1, build_up: true, build_up_rate: 25.0, ..base.clone() }),
|
||||
g(
|
||||
"Airbrush Pressure Flow",
|
||||
BrushSettings {
|
||||
size: 45.0,
|
||||
size: 120.0,
|
||||
hardness: 0.0,
|
||||
transfer: Transfer { enabled: true, opacity: Dynamic::controlled(Control::PenPressure), ..Default::default() },
|
||||
flow: 0.15,
|
||||
spacing: 0.1,
|
||||
build_up: true,
|
||||
build_up_rate: 30.0,
|
||||
transfer: transfer(Dynamic::default(), pressure()),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
preset("Airbrush Soft", BrushSettings { size: 80.0, hardness: 0.0, flow: 0.1, spacing: 0.1, build_up: true, build_up_rate: 25.0, ..base.clone() }),
|
||||
preset("Hard Pencil", BrushSettings { size: 3.0, hardness: 1.0, aliased: true, spacing: 0.1, ..base.clone() }),
|
||||
preset("Calligraphy Flat", BrushSettings { size: 28.0, hardness: 1.0, roundness: 0.2, angle: 45.0, spacing: 0.05, ..base.clone() }),
|
||||
preset(
|
||||
g("Calligraphy Flat", BrushSettings { size: 28.0, hardness: 1.0, roundness: 0.2, angle: 45.0, spacing: 0.05, ..base.clone() }),
|
||||
]
|
||||
}
|
||||
|
||||
fn dry_media(base: &BrushSettings) -> Vec<BrushPreset> {
|
||||
let g = |n: &str, b: BrushSettings| preset("Dry Media", n, b);
|
||||
vec![
|
||||
g("Hard Pencil", BrushSettings { size: 3.0, hardness: 1.0, aliased: true, spacing: 0.1, ..base.clone() }),
|
||||
g(
|
||||
"Soft Pencil",
|
||||
BrushSettings {
|
||||
size: 6.0,
|
||||
hardness: 0.6,
|
||||
spacing: 0.08,
|
||||
shape_dynamics: size_dyn(pressure_min(0.4)),
|
||||
transfer: transfer(pressure_min(0.15), Dynamic::default()),
|
||||
texture: Texture { scale: 0.5, ..paper(21, 0.8) },
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Chalk",
|
||||
BrushSettings {
|
||||
size: 40.0,
|
||||
@@ -46,16 +95,172 @@ pub fn builtin() -> Vec<BrushPreset> {
|
||||
size: Dynamic { jitter: 0.15, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
texture: paper(3, 0.6),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Charcoal",
|
||||
BrushSettings {
|
||||
size: 45.0,
|
||||
tip: TipShape::Sampled(charcoal_tip(64, 4)),
|
||||
spacing: 0.12,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, size: pressure_min(0.5), angle: Dynamic::controlled(Control::Direction), ..Default::default() },
|
||||
transfer: transfer(pressure_min(0.3), Dynamic::default()),
|
||||
texture: Texture { mode: MaskMode::Subtract, ..paper(5, 0.7) },
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Pastel",
|
||||
BrushSettings {
|
||||
size: 50.0,
|
||||
tip: TipShape::Sampled(chalk_tip(64, 31)),
|
||||
spacing: 0.15,
|
||||
shape_dynamics: ShapeDynamics {
|
||||
enabled: true,
|
||||
angle: Dynamic::jitter(1.0),
|
||||
roundness: Dynamic { jitter: 0.3, minimum: 0.5, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(0.25), ..Default::default() },
|
||||
texture: Texture {
|
||||
enabled: true,
|
||||
pattern: Pattern::Procedural { style: PatternStyle::Paper, size: 128, seed: 3 },
|
||||
depth: 0.6,
|
||||
pattern: Pattern::Procedural { style: PatternStyle::Canvas, size: 64, seed: 2 },
|
||||
depth: 0.55,
|
||||
mode: MaskMode::Subtract,
|
||||
..Default::default()
|
||||
},
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
preset(
|
||||
g(
|
||||
"Wax Crayon",
|
||||
BrushSettings {
|
||||
size: 22.0,
|
||||
hardness: 0.85,
|
||||
roundness: 0.7,
|
||||
spacing: 0.08,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::controlled(Control::Direction), ..Default::default() },
|
||||
texture: Texture { each_tip: true, mode: MaskMode::HardMix, depth_jitter: Dynamic::jitter(0.3), ..paper(17, 0.9) },
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Dry Bristle",
|
||||
BrushSettings {
|
||||
size: 50.0,
|
||||
tip: TipShape::Sampled(bristle_tip(64, 5)),
|
||||
spacing: 0.04,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::controlled(Control::Direction), ..Default::default() },
|
||||
transfer: Transfer {
|
||||
enabled: true,
|
||||
flow: Dynamic { control: Control::Fade, fade_steps: 400, minimum: 0.1, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Rake",
|
||||
BrushSettings {
|
||||
size: 60.0,
|
||||
tip: TipShape::Sampled(rake_tip(64, 3, 7)),
|
||||
spacing: 0.03,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::controlled(Control::Direction), ..Default::default() },
|
||||
transfer: transfer(pressure_min(0.25), Dynamic::default()),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn wet_media(base: &BrushSettings) -> Vec<BrushPreset> {
|
||||
let g = |n: &str, b: BrushSettings| preset("Wet Media", n, b);
|
||||
vec![
|
||||
g(
|
||||
"Watercolor Wet Edges",
|
||||
BrushSettings {
|
||||
size: 60.0,
|
||||
hardness: 0.3,
|
||||
wet_edges: true,
|
||||
texture: Texture { scale: 1.5, ..paper(9, 0.35) }.with_pattern_size(256),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Watercolor Wash",
|
||||
BrushSettings {
|
||||
size: 140.0,
|
||||
hardness: 0.0,
|
||||
flow: 0.35,
|
||||
spacing: 0.12,
|
||||
wet_edges: true,
|
||||
shape_dynamics: size_dyn(pressure_min(0.35)),
|
||||
transfer: transfer(Dynamic::default(), Dynamic { jitter: 0.2, ..pressure() }),
|
||||
texture: Texture { scale: 2.0, ..paper(13, 0.25) }.with_pattern_size(256),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Ink Pen",
|
||||
BrushSettings {
|
||||
size: 8.0,
|
||||
hardness: 1.0,
|
||||
spacing: 0.05,
|
||||
shape_dynamics: size_dyn(pressure_min(0.15)),
|
||||
smoothing: Smoothing { amount: 0.35, ..Default::default() },
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Oil Bristle",
|
||||
BrushSettings {
|
||||
size: 70.0,
|
||||
tip: TipShape::Sampled(bristle_tip(64, 41)),
|
||||
spacing: 0.03,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::controlled(Control::Direction), size: pressure_min(0.6), ..Default::default() },
|
||||
color_dynamics: ColorDynamics { enabled: true, per_tip: false, fg_bg: Dynamic::jitter(0.15), brightness_jitter: 0.05, ..Default::default() },
|
||||
texture: Texture {
|
||||
enabled: true,
|
||||
pattern: Pattern::Procedural { style: PatternStyle::Canvas, size: 64, seed: 4 },
|
||||
depth: 0.3,
|
||||
..Default::default()
|
||||
},
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Gouache Flat",
|
||||
BrushSettings {
|
||||
size: 40.0,
|
||||
hardness: 0.9,
|
||||
roundness: 0.35,
|
||||
spacing: 0.05,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::controlled(Control::Direction), ..Default::default() },
|
||||
transfer: transfer(Dynamic::default(), Dynamic { control: Control::Fade, fade_steps: 600, minimum: 0.35, ..Default::default() }),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Ink Wash Sponge",
|
||||
BrushSettings {
|
||||
size: 70.0,
|
||||
tip: TipShape::Sampled(sponge_tip(96, 8)),
|
||||
spacing: 0.3,
|
||||
flow: 0.6,
|
||||
wet_edges: true,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::jitter(1.0), size: Dynamic::jitter(0.2), ..Default::default() },
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn special_effects(base: &BrushSettings) -> Vec<BrushPreset> {
|
||||
let g = |n: &str, b: BrushSettings| preset("Special Effects", n, b);
|
||||
vec![
|
||||
g(
|
||||
"Spatter",
|
||||
BrushSettings {
|
||||
size: 60.0,
|
||||
@@ -73,38 +278,99 @@ pub fn builtin() -> Vec<BrushPreset> {
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
preset(
|
||||
"Dry Bristle",
|
||||
g(
|
||||
"Stipple",
|
||||
BrushSettings {
|
||||
size: 40.0,
|
||||
tip: TipShape::Sampled(spatter_tip(64, 77, 40)),
|
||||
spacing: 0.5,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::jitter(1.0), flip_x_jitter: true, ..Default::default() },
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(0.6), both_axes: true, ..Default::default() },
|
||||
transfer: transfer(pressure(), Dynamic::default()),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Confetti",
|
||||
BrushSettings {
|
||||
size: 24.0,
|
||||
hardness: 1.0,
|
||||
roundness: 0.5,
|
||||
spacing: 1.2,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::jitter(1.0), size: Dynamic::jitter(0.5), ..Default::default() },
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(3.0), both_axes: true, count: 3, ..Default::default() },
|
||||
color_dynamics: ColorDynamics { enabled: true, per_tip: true, hue_jitter: 1.0, saturation_jitter: 0.2, purity: 0.5, ..Default::default() },
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Foliage",
|
||||
BrushSettings {
|
||||
size: 50.0,
|
||||
tip: TipShape::Sampled(bristle_tip(64, 5)),
|
||||
spacing: 0.04,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::controlled(Control::Direction), ..Default::default() },
|
||||
transfer: Transfer {
|
||||
tip: TipShape::Sampled(leaf_tip(64)),
|
||||
spacing: 0.35,
|
||||
shape_dynamics: ShapeDynamics {
|
||||
enabled: true,
|
||||
flow: Dynamic { control: Control::Fade, fade_steps: 400, minimum: 0.1, ..Default::default() },
|
||||
size: Dynamic { jitter: 0.5, minimum: 0.3, ..Default::default() },
|
||||
angle: Dynamic::jitter(1.0),
|
||||
roundness: Dynamic { jitter: 0.4, minimum: 0.4, ..Default::default() },
|
||||
flip_y_jitter: true,
|
||||
..Default::default()
|
||||
},
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(1.2), both_axes: true, count: 2, count_jitter: Dynamic::jitter(0.5) },
|
||||
color_dynamics: ColorDynamics {
|
||||
enabled: true,
|
||||
per_tip: true,
|
||||
fg_bg: Dynamic::jitter(0.6),
|
||||
hue_jitter: 0.04,
|
||||
brightness_jitter: 0.12,
|
||||
..Default::default()
|
||||
},
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
preset(
|
||||
"Watercolor Wet Edges",
|
||||
g(
|
||||
"Grass",
|
||||
BrushSettings {
|
||||
size: 70.0,
|
||||
tip: TipShape::Sampled(grass_tip(96, 6, 11)),
|
||||
spacing: 0.25,
|
||||
shape_dynamics: ShapeDynamics {
|
||||
enabled: true,
|
||||
size: Dynamic { jitter: 0.4, minimum: 0.4, ..pressure() },
|
||||
angle: Dynamic::jitter(0.06),
|
||||
flip_x_jitter: true,
|
||||
..Default::default()
|
||||
},
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(0.3), ..Default::default() },
|
||||
color_dynamics: ColorDynamics { enabled: true, per_tip: true, fg_bg: Dynamic::jitter(0.7), hue_jitter: 0.03, ..Default::default() },
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Sparkle",
|
||||
BrushSettings {
|
||||
size: 40.0,
|
||||
tip: TipShape::Sampled(star_tip(64)),
|
||||
spacing: 1.5,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, size: Dynamic { jitter: 0.8, minimum: 0.15, ..Default::default() }, ..Default::default() },
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(2.5), both_axes: true, ..Default::default() },
|
||||
transfer: transfer(Dynamic::jitter(0.5), Dynamic::default()),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
g(
|
||||
"Sponge Texture",
|
||||
BrushSettings {
|
||||
size: 60.0,
|
||||
hardness: 0.3,
|
||||
wet_edges: true,
|
||||
texture: Texture {
|
||||
enabled: true,
|
||||
pattern: Pattern::Procedural { style: PatternStyle::Paper, size: 256, seed: 9 },
|
||||
depth: 0.35,
|
||||
scale: 1.5,
|
||||
..Default::default()
|
||||
},
|
||||
tip: TipShape::Sampled(sponge_tip(64, 19)),
|
||||
spacing: 0.45,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::jitter(1.0), ..Default::default() },
|
||||
transfer: transfer(Dynamic::jitter(0.3), Dynamic::default()),
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
preset(
|
||||
g(
|
||||
"Canvas Texture",
|
||||
BrushSettings {
|
||||
size: 50.0,
|
||||
@@ -119,20 +385,22 @@ pub fn builtin() -> Vec<BrushPreset> {
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
preset(
|
||||
"Confetti",
|
||||
g(
|
||||
"Halftone Dots",
|
||||
BrushSettings {
|
||||
size: 24.0,
|
||||
hardness: 1.0,
|
||||
roundness: 0.5,
|
||||
spacing: 1.2,
|
||||
shape_dynamics: ShapeDynamics { enabled: true, angle: Dynamic::jitter(1.0), size: Dynamic::jitter(0.5), ..Default::default() },
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(3.0), both_axes: true, count: 3, ..Default::default() },
|
||||
color_dynamics: ColorDynamics { enabled: true, per_tip: true, hue_jitter: 1.0, saturation_jitter: 0.2, purity: 0.5, ..Default::default() },
|
||||
size: 80.0,
|
||||
hardness: 0.5,
|
||||
texture: Texture {
|
||||
enabled: true,
|
||||
pattern: Pattern::Procedural { style: PatternStyle::Dots, size: 64, seed: 1 },
|
||||
mode: MaskMode::HardMix,
|
||||
depth: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
preset(
|
||||
g(
|
||||
"Dual Grain",
|
||||
BrushSettings {
|
||||
size: 50.0,
|
||||
@@ -149,13 +417,57 @@ pub fn builtin() -> Vec<BrushPreset> {
|
||||
flip: true,
|
||||
..Default::default()
|
||||
},
|
||||
..base
|
||||
..base.clone()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
fn with_pattern_size(mut self, n: u32) -> Self {
|
||||
if let Pattern::Procedural { size, .. } = &mut self.pattern {
|
||||
*size = n;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in preset set, in display order (grouped by [`GROUPS`]).
|
||||
pub fn builtin() -> Vec<BrushPreset> {
|
||||
let base = BrushSettings { pressure_size: false, spacing: 0.25, ..Default::default() };
|
||||
let mut v = general(&base);
|
||||
v.extend(dry_media(&base));
|
||||
v.extend(wet_media(&base));
|
||||
v.extend(special_effects(&base));
|
||||
v
|
||||
}
|
||||
|
||||
/// Find a preset by name (case-insensitive).
|
||||
pub fn find<'a>(presets: &'a [BrushPreset], name: &str) -> Option<&'a BrushPreset> {
|
||||
presets.iter().find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtins_are_grouped_and_unique() {
|
||||
let v = builtin();
|
||||
assert!((30..=40).contains(&v.len()), "{}", v.len());
|
||||
let mut names: Vec<&str> = v.iter().map(|p| p.name.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), v.len(), "duplicate preset names");
|
||||
for g in GROUPS {
|
||||
assert!(v.iter().filter(|p| p.group == g).count() >= 6, "{g}");
|
||||
}
|
||||
assert!(v.iter().all(|p| p.builtin && GROUPS.contains(&p.group.as_str())));
|
||||
// Groups are contiguous, in panel order.
|
||||
let order: Vec<usize> = v.iter().filter_map(|p| GROUPS.iter().position(|g| *g == p.group)).collect();
|
||||
assert!(order.windows(2).all(|w| w[0] <= w[1]));
|
||||
// Pressure variants respond to pressure.
|
||||
let ps = find(&v, "Hard Round Pressure Size").map(|p| p.brush.shape_dynamics.size.control);
|
||||
assert_eq!(ps, Some(Control::PenPressure));
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,117 @@ pub fn bristle_tip(n: u32, seed: u32) -> GrayTile {
|
||||
})
|
||||
}
|
||||
|
||||
/// A charcoal tip: a squat ellipse of broken horizontal streaks with a gritty edge.
|
||||
pub fn charcoal_tip(n: u32, seed: u32) -> GrayTile {
|
||||
let salt = u64::from(seed) + 404;
|
||||
let c = n as f32 / 2.0;
|
||||
GrayTile::from_fn(n, n, |x, y| {
|
||||
let (dx, dy) = ((x as f32 + 0.5 - c) / c, (y as f32 + 0.5 - c) / c);
|
||||
let r = (dx * dx + (dy * 1.6).powi(2)).sqrt();
|
||||
let edge = 0.85 + 0.15 * value_noise(dx * 6.0 + 9.0, dy * 6.0 + 9.0, 64, salt);
|
||||
if r > edge {
|
||||
return 0.0;
|
||||
}
|
||||
// Streaks: per-row density modulated along x so they break up.
|
||||
let row = hash2(0, y as i32, salt);
|
||||
let along = value_noise(x as f32 / 6.0, y as f32 / 1.5, 1 << 12, salt ^ 0x77);
|
||||
let grit = hash2(x as i32, y as i32, salt ^ 0x13);
|
||||
let v = (0.35 + 0.65 * row) * (0.4 + 0.6 * along) * if grit < 0.15 { 0.2 } else { 1.0 };
|
||||
v * ((edge - r) * 6.0).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// A leaf: a pointed lens shape with a lighter midrib, stem towards -x.
|
||||
pub fn leaf_tip(n: u32) -> GrayTile {
|
||||
let c = n as f32 / 2.0;
|
||||
GrayTile::from_fn(n, n, |x, y| {
|
||||
let (u, v) = ((x as f32 + 0.5 - c) / c, (y as f32 + 0.5 - c) / c);
|
||||
// Half-width of the blade at u: widest a little behind the middle, pointed at both ends.
|
||||
let t = ((u + 1.0) / 2.0).clamp(0.0, 1.0);
|
||||
let half = 0.42 * (std::f32::consts::PI * t).sin() * (1.0 - 0.25 * t);
|
||||
let inside = ((half - v.abs()) * c).clamp(0.0, 1.0);
|
||||
let rib = 1.0 - 0.45 * (1.0 - (v.abs() * c / 1.2).clamp(0.0, 1.0));
|
||||
let veins = 1.0 - 0.2 * (1.0 - ((((u * 7.0 - v.abs() * 5.0).fract()) - 0.5).abs() * 8.0).clamp(0.0, 1.0));
|
||||
let stem = if u < -0.75 && v.abs() * c < 0.9 { 1.0 } else { 0.0 };
|
||||
(inside * rib * veins).max(stem)
|
||||
})
|
||||
}
|
||||
|
||||
/// Grass: a tuft of tapered, slightly curved blades rising from the bottom edge.
|
||||
pub fn grass_tip(n: u32, seed: u32, blades: u32) -> GrayTile {
|
||||
let salt = u64::from(seed) + 505;
|
||||
let s = n as f32;
|
||||
let bl: Vec<(f32, f32, f32, f32)> = (0..blades)
|
||||
.map(|i| {
|
||||
let base = (0.3 + 0.4 * hash2(i as i32, 0, salt)) * s;
|
||||
let height = (0.55 + 0.42 * hash2(i as i32, 1, salt)) * s;
|
||||
let lean = (hash2(i as i32, 2, salt) - 0.5) * 0.9;
|
||||
let width = (0.025 + 0.025 * hash2(i as i32, 3, salt)) * s;
|
||||
(base, height, lean, width)
|
||||
})
|
||||
.collect();
|
||||
GrayTile::from_fn(n, n, |x, y| {
|
||||
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let up = s - py;
|
||||
bl.iter().fold(0.0f32, |acc, &(base, height, lean, width)| {
|
||||
if up < 0.0 || up > height {
|
||||
return acc;
|
||||
}
|
||||
let t = up / height;
|
||||
let cx = base + lean * height * t * t;
|
||||
let w = width * (1.0 - t) + 0.3;
|
||||
acc.max((w - (px - cx).abs()).clamp(0.0, 1.0))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A sea sponge: a disc riddled with holes of different sizes.
|
||||
pub fn sponge_tip(n: u32, seed: u32) -> GrayTile {
|
||||
let salt = u64::from(seed) + 606;
|
||||
let c = n as f32 / 2.0;
|
||||
GrayTile::from_fn(n, n, |x, y| {
|
||||
let (dx, dy) = (x as f32 + 0.5 - c, y as f32 + 0.5 - c);
|
||||
let r = (dx * dx + dy * dy).sqrt() / c;
|
||||
let edge = 0.78 + 0.2 * value_noise((dy.atan2(dx) + std::f32::consts::PI) * 2.5, 0.5, 16, salt);
|
||||
let disc = ((edge - r) * 6.0).clamp(0.0, 1.0);
|
||||
let pores = fbm(x as f32, y as f32, n as f32, 6, 3, salt);
|
||||
disc * ((pores - 0.42) * 6.0).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// A four-point sparkle with a soft glow.
|
||||
pub fn star_tip(n: u32) -> GrayTile {
|
||||
let c = n as f32 / 2.0;
|
||||
GrayTile::from_fn(n, n, |x, y| {
|
||||
let (u, v) = (((x as f32 + 0.5 - c) / c).abs(), ((y as f32 + 0.5 - c) / c).abs());
|
||||
let ray = |a: f32, b: f32| ((1.0 - a) * (1.0 - (b / (0.08 * (1.0 - a) + 0.005)).min(1.0))).max(0.0);
|
||||
let glow = (1.0 - (u * u + v * v).sqrt() * 2.5).max(0.0).powi(2);
|
||||
ray(u, v).max(ray(v, u)).max(glow).min(1.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// A rake: a row of separate round bristles of slightly different sizes.
|
||||
pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile {
|
||||
let salt = u64::from(seed) + 707;
|
||||
let s = n as f32;
|
||||
let k = bristles.max(1);
|
||||
let dots: Vec<(f32, f32, f32)> = (0..k)
|
||||
.map(|i| {
|
||||
let x = s * (i as f32 + 0.5) / k as f32;
|
||||
let y = s / 2.0 + (hash2(i as i32, 0, salt) - 0.5) * s * 0.08;
|
||||
let r = s / k as f32 * (0.22 + 0.18 * hash2(i as i32, 1, salt));
|
||||
(x, y, r)
|
||||
})
|
||||
.collect();
|
||||
GrayTile::from_fn(n, n, |x, y| {
|
||||
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
dots.iter().fold(0.0f32, |acc, &(dx, dy, r)| {
|
||||
let d = ((px - dx).powi(2) + (py - dy).powi(2)).sqrt();
|
||||
acc.max((r + 0.5 - d).clamp(0.0, 1.0) * 0.9)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -146,7 +257,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tips_have_paint() {
|
||||
for t in [chalk_tip(48, 1), spatter_tip(48, 1, 12), bristle_tip(48, 1)] {
|
||||
for t in [
|
||||
chalk_tip(48, 1),
|
||||
spatter_tip(48, 1, 12),
|
||||
bristle_tip(48, 1),
|
||||
charcoal_tip(48, 1),
|
||||
leaf_tip(48),
|
||||
grass_tip(48, 1, 9),
|
||||
sponge_tip(48, 1),
|
||||
star_tip(48),
|
||||
rake_tip(48, 1, 7),
|
||||
] {
|
||||
assert!(t.is_valid());
|
||||
let sum: f32 = t.to_f32().iter().sum();
|
||||
assert!(sum > 48.0, "{sum}");
|
||||
|
||||
@@ -101,7 +101,8 @@ impl<'de> Deserialize<'de> for GrayTile {
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
fn b64_encode(b: &[u8]) -> String {
|
||||
/// Standard base64 with padding (the tile serde form; commands taking file bytes).
|
||||
pub fn b64_encode(b: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
|
||||
for c in b.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
@@ -113,7 +114,8 @@ fn b64_encode(b: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn b64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
/// Decode standard base64 (whitespace ignored); `None` on invalid input.
|
||||
pub fn b64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| -> Option<u32> {
|
||||
Some(match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
|
||||
@@ -28,3 +28,10 @@ path = "fuzz_targets/descriptor.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "abr"
|
||||
path = "fuzz_targets/abr.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Fuzz the `.abr` brush parser: any input must return `Ok` or `Err`, never panic, and anything
|
||||
//! that parses must keep its samples within the advertised bounds.
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::abr;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(f) = abr::parse(data) {
|
||||
for s in &f.samples {
|
||||
assert_eq!(s.data.len(), s.width as usize * s.height as usize * usize::from(s.depth / 8));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
//! Photoshop brush files (`.abr`): the legacy v1/v2 layout and the sectioned v6+ layout.
|
||||
//!
|
||||
//! Implemented clean-room from public descriptions of the format (the layout notes published with
|
||||
//! open-source readers such as GIMP's and ag-psd's, and observation of files written by Photoshop).
|
||||
//! No code was copied. All numbers are big-endian.
|
||||
//!
|
||||
//! ```text
|
||||
//! v1 / v2 := version u16 (1|2), count u16, count × brush
|
||||
//! brush := type u16 (1 computed, 2 sampled), length u32, body[length]
|
||||
//! computed := misc u32, spacing u16 (%), diameter u16, roundness u16 (%), angle i16, hardness u16 (%)
|
||||
//! sampled := misc u32, spacing u16 (%), [v2: name (u32 length + UTF-16 incl. NUL)],
|
||||
//! anti-alias u8, short bounds 4 × i16, bounds 4 × i32 (top, left, bottom, right),
|
||||
//! depth u16 (8|16), compression u8 (0 raw, 1 PackBits with a u16 row-length table)
|
||||
//!
|
||||
//! v6+ := version u16 (6..=10), subversion u16 (1|2), sections…
|
||||
//! section := "8BIM", key [4], length u32, data[length] (padded to 4 bytes)
|
||||
//! "samp" := entries: length u32 (padded to 4), Pascal uuid, skip (10 if subversion 1,
|
||||
//! 264 if 2), bounds 4 × i32, depth i16, compression u8, data
|
||||
//! "patt" := patterns as in the `Patt` global block
|
||||
//! "desc" := version u32 (16) + ActionDescriptor; key `Brsh` lists `brushPreset` objects
|
||||
//! ```
|
||||
//!
|
||||
//! [`parse`] never panics: every length is bounds-checked, sizes are capped ([`MAX_EDGE`],
|
||||
//! [`MAX_TOTAL_BYTES`]) and a damaged brush is skipped with a warning when the rest of the file
|
||||
//! is still readable. [`write_v12`] and [`write_v6`] produce files in the same layouts (used by
|
||||
//! the tests and fuzz seeds; exporting brushes reuses them).
|
||||
|
||||
use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes};
|
||||
use crate::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||
use crate::error::{PsdError, Result};
|
||||
use crate::header::Version;
|
||||
use crate::patterns::{PsdPattern, parse_pattern_block, write_pattern_block};
|
||||
|
||||
/// Largest sampled tip edge accepted (Photoshop's own limit is 5000 px).
|
||||
pub const MAX_EDGE: u32 = 8192;
|
||||
/// Most brushes read from one file.
|
||||
pub const MAX_BRUSHES: usize = 20_000;
|
||||
/// Cap on decoded sample bytes over the whole file.
|
||||
pub const MAX_TOTAL_BYTES: usize = 512 << 20;
|
||||
|
||||
/// A sampled tip: one grayscale plane where the maximum value is full paint.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AbrSample {
|
||||
/// The uuid that `sampledData` in a preset descriptor refers to (v6+), or empty.
|
||||
pub id: String,
|
||||
/// Width in pixels.
|
||||
pub width: u32,
|
||||
/// Height in pixels.
|
||||
pub height: u32,
|
||||
/// Bits per sample: 8 or 16.
|
||||
pub depth: u16,
|
||||
/// Decoded samples, row-major (`width × height × depth / 8` bytes, 16-bit big-endian).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl AbrSample {
|
||||
/// Samples as 0..1 paint amounts.
|
||||
pub fn to_unit(&self) -> Vec<f32> {
|
||||
if self.depth == 16 {
|
||||
self.data.as_chunks::<2>().0.iter().map(|c| f32::from(u16::from_be_bytes(*c)) / 65535.0).collect()
|
||||
} else {
|
||||
self.data.iter().map(|&v| f32::from(v) / 255.0).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A legacy (v1/v2) tip.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LegacyTip {
|
||||
/// Computed elliptical tip.
|
||||
Computed {
|
||||
/// Diameter in pixels.
|
||||
diameter: u16,
|
||||
/// 0..100 %.
|
||||
hardness: u16,
|
||||
/// Degrees.
|
||||
angle: i16,
|
||||
/// 0..100 %.
|
||||
roundness: u16,
|
||||
},
|
||||
/// Sampled tip.
|
||||
Sampled(AbrSample),
|
||||
}
|
||||
|
||||
/// A legacy (v1/v2) brush.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LegacyBrush {
|
||||
/// Name (v2 sampled brushes only; empty otherwise).
|
||||
pub name: String,
|
||||
/// Spacing in percent of the diameter (0 = spacing off).
|
||||
pub spacing: u16,
|
||||
/// Anti-aliasing flag (sampled tips).
|
||||
pub anti_alias: bool,
|
||||
/// The tip.
|
||||
pub tip: LegacyTip,
|
||||
}
|
||||
|
||||
/// A parsed brush file.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct AbrFile {
|
||||
/// File version (1, 2, 6, 7, 9 or 10).
|
||||
pub version: u16,
|
||||
/// v6+ subversion (1 or 2); 0 for v1/v2.
|
||||
pub subversion: u16,
|
||||
/// v1/v2 brushes, in file order.
|
||||
pub legacy: Vec<LegacyBrush>,
|
||||
/// v6+ sampled tips (`samp`), in file order.
|
||||
pub samples: Vec<AbrSample>,
|
||||
/// v6+ embedded patterns (`patt`), referenced by texture settings.
|
||||
pub patterns: Vec<PsdPattern>,
|
||||
/// v6+ brush presets: the `brushPreset` objects of the `desc` section, in file order.
|
||||
pub presets: Vec<Descriptor>,
|
||||
/// Problems that did not stop the parse (skipped brushes, unknown sections…).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
struct Rd<'a> {
|
||||
b: &'a [u8],
|
||||
p: usize,
|
||||
}
|
||||
|
||||
impl<'a> Rd<'a> {
|
||||
fn new(b: &'a [u8]) -> Self {
|
||||
Rd { b, p: 0 }
|
||||
}
|
||||
fn left(&self) -> usize {
|
||||
self.b.len().saturating_sub(self.p)
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
|
||||
let end = self.p.checked_add(n).ok_or(PsdError::LimitExceeded("abr length overflow"))?;
|
||||
let s = self.b.get(self.p..end).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: end.saturating_sub(self.b.len()) })?;
|
||||
self.p = end;
|
||||
Ok(s)
|
||||
}
|
||||
fn arr<const N: usize>(&mut self) -> Result<[u8; N]> {
|
||||
let s = self.take(N)?;
|
||||
let mut a = [0u8; N];
|
||||
a.copy_from_slice(s);
|
||||
Ok(a)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8> {
|
||||
Ok(self.arr::<1>()?[0])
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16> {
|
||||
Ok(u16::from_be_bytes(self.arr()?))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32> {
|
||||
Ok(u32::from_be_bytes(self.arr()?))
|
||||
}
|
||||
fn i32(&mut self) -> Result<i32> {
|
||||
Ok(i32::from_be_bytes(self.arr()?))
|
||||
}
|
||||
fn skip(&mut self, n: usize) -> Result<()> {
|
||||
self.take(n).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes budget shared by all samples of one file.
|
||||
struct Budget(usize);
|
||||
|
||||
impl Budget {
|
||||
fn spend(&mut self, n: usize) -> Result<()> {
|
||||
self.0 = self.0.checked_sub(n).ok_or(PsdError::LimitExceeded("abr samples exceed MAX_TOTAL_BYTES"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads bounds (top, left, bottom, right), depth and compression, then the pixel data.
|
||||
fn read_sample_body(r: &mut Rd, id: String, budget: &mut Budget) -> Result<AbrSample> {
|
||||
let (top, left, bottom, right) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?);
|
||||
let w = i64::from(right) - i64::from(left);
|
||||
let h = i64::from(bottom) - i64::from(top);
|
||||
if w <= 0 || h <= 0 {
|
||||
return Err(PsdError::invalid(format!("brush sample bounds {w}×{h}")));
|
||||
}
|
||||
if w > i64::from(MAX_EDGE) || h > i64::from(MAX_EDGE) {
|
||||
return Err(PsdError::LimitExceeded("brush sample larger than MAX_EDGE"));
|
||||
}
|
||||
let (w, h) = (w as u32, h as u32);
|
||||
let depth = r.u16()?;
|
||||
if depth != 8 && depth != 16 {
|
||||
return Err(PsdError::invalid(format!("brush sample depth {depth}")));
|
||||
}
|
||||
let comp = r.u8()?;
|
||||
let compression = match comp {
|
||||
0 => Compression::Raw,
|
||||
1 => Compression::Rle,
|
||||
c => return Err(PsdError::Unsupported(format!("brush sample compression {c}"))),
|
||||
};
|
||||
let layout = PlaneLayout { planes: 1, width: w as usize, height: h as usize, depth, version: Version::Psd };
|
||||
budget.spend(layout.decoded_len()?)?;
|
||||
let rest = r.take(r.left())?;
|
||||
let data = decode_planes(compression, rest, &layout)?;
|
||||
Ok(AbrSample { id, width: w, height: h, depth, data })
|
||||
}
|
||||
|
||||
fn read_ucs2(r: &mut Rd) -> Result<String> {
|
||||
let n = r.u32()? as usize;
|
||||
if n > 4096 {
|
||||
return Err(PsdError::LimitExceeded("abr name length"));
|
||||
}
|
||||
let units: Vec<u16> = r.take(n * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
Ok(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string())
|
||||
}
|
||||
|
||||
fn parse_v12(r: &mut Rd, version: u16, out: &mut AbrFile) -> Result<()> {
|
||||
let count = r.u16()?;
|
||||
let mut budget = Budget(MAX_TOTAL_BYTES);
|
||||
for i in 0..count {
|
||||
if r.left() == 0 {
|
||||
out.warnings.push(format!("file ends after {i} of {count} brushes"));
|
||||
break;
|
||||
}
|
||||
let ty = r.u16()?;
|
||||
let len = r.u32()? as usize;
|
||||
let body = match r.take(len) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
out.warnings.push(format!("brush {}: truncated", i + 1));
|
||||
break;
|
||||
}
|
||||
};
|
||||
let mut b = Rd::new(body);
|
||||
let parsed = (|| -> Result<Option<LegacyBrush>> {
|
||||
match ty {
|
||||
1 => {
|
||||
let _misc = b.u32()?;
|
||||
let spacing = b.u16()?;
|
||||
let diameter = b.u16()?;
|
||||
let roundness = b.u16()?;
|
||||
let angle = b.u16()? as i16;
|
||||
let hardness = b.u16()?;
|
||||
Ok(Some(LegacyBrush { name: String::new(), spacing, anti_alias: true, tip: LegacyTip::Computed { diameter, hardness, angle, roundness } }))
|
||||
}
|
||||
2 => {
|
||||
let _misc = b.u32()?;
|
||||
let spacing = b.u16()?;
|
||||
let name = if version == 2 { read_ucs2(&mut b)? } else { String::new() };
|
||||
let anti_alias = b.u8()? != 0;
|
||||
b.skip(8)?; // short bounds (superseded by the 32-bit bounds)
|
||||
let s = read_sample_body(&mut b, String::new(), &mut budget)?;
|
||||
Ok(Some(LegacyBrush { name, spacing, anti_alias, tip: LegacyTip::Sampled(s) }))
|
||||
}
|
||||
other => {
|
||||
out.warnings.push(format!("brush {}: unknown brush type {other} skipped", i + 1));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
})();
|
||||
match parsed {
|
||||
Ok(Some(lb)) => out.legacy.push(lb),
|
||||
Ok(None) => {}
|
||||
Err(PsdError::LimitExceeded(m)) if m.contains("MAX_TOTAL_BYTES") => return Err(PsdError::LimitExceeded(m)),
|
||||
Err(e) => out.warnings.push(format!("brush {}: {e}; skipped", i + 1)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_samp(data: &[u8], subversion: u16, out: &mut AbrFile, budget: &mut Budget) -> Result<()> {
|
||||
let mut r = Rd::new(data);
|
||||
while r.left() >= 4 {
|
||||
if out.samples.len() >= MAX_BRUSHES {
|
||||
out.warnings.push("too many sampled tips; the rest were skipped".into());
|
||||
break;
|
||||
}
|
||||
let len = r.u32()? as usize;
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
let padded = len.checked_add(3).ok_or(PsdError::LimitExceeded("abr sample length"))? & !3;
|
||||
let body = match r.take(padded.min(r.left())) {
|
||||
Ok(b) if b.len() >= len => b,
|
||||
_ => {
|
||||
out.warnings.push(format!("sampled tip {}: truncated", out.samples.len() + 1));
|
||||
break;
|
||||
}
|
||||
};
|
||||
let mut b = Rd::new(body);
|
||||
let parsed = (|| -> Result<AbrSample> {
|
||||
let n = b.u8()? as usize;
|
||||
let id = String::from_utf8_lossy(b.take(n)?).to_string();
|
||||
b.skip(if subversion == 1 { 10 } else { 264 })?;
|
||||
read_sample_body(&mut b, id, budget)
|
||||
})();
|
||||
match parsed {
|
||||
Ok(s) => out.samples.push(s),
|
||||
Err(PsdError::LimitExceeded(m)) if m.contains("MAX_TOTAL_BYTES") => return Err(PsdError::LimitExceeded(m)),
|
||||
Err(e) => out.warnings.push(format!("sampled tip {}: {e}; skipped", out.samples.len() + 1)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_desc(data: &[u8], out: &mut AbrFile) {
|
||||
match VersionedDescriptor::parse_prefix(data) {
|
||||
Ok((vd, _)) => match vd.descriptor.get("Brsh") {
|
||||
Some(Value::List(items)) => {
|
||||
for v in items.iter().take(MAX_BRUSHES) {
|
||||
if let Value::Descriptor(d) = v {
|
||||
out.presets.push(d.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => out.warnings.push("brush settings (desc) hold no brush list".into()),
|
||||
},
|
||||
Err(e) => out.warnings.push(format!("brush settings (desc) unreadable: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> {
|
||||
out.subversion = r.u16()?;
|
||||
if !matches!(out.subversion, 1 | 2) {
|
||||
return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion)));
|
||||
}
|
||||
let mut budget = Budget(MAX_TOTAL_BYTES);
|
||||
while r.left() >= 12 {
|
||||
let sig = r.arr::<4>()?;
|
||||
if &sig != b"8BIM" {
|
||||
out.warnings.push(format!("unexpected data at offset {} ignored", r.p - 4));
|
||||
break;
|
||||
}
|
||||
let key = r.arr::<4>()?;
|
||||
let len = r.u32()? as usize;
|
||||
let data = match r.take(len) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
out.warnings.push(format!("section {} truncated", String::from_utf8_lossy(&key)));
|
||||
r.take(r.left())?
|
||||
}
|
||||
};
|
||||
// Sections are padded to 4 bytes; tolerate writers that don't pad.
|
||||
let pad = (4 - len % 4) % 4;
|
||||
if pad > 0 && r.b.get(r.p..r.p + 4) != Some(b"8BIM") && r.b.get(r.p + pad..r.p + pad + 4) == Some(b"8BIM") {
|
||||
r.p += pad;
|
||||
}
|
||||
match &key {
|
||||
b"samp" => read_samp(data, out.subversion, out, &mut budget)?,
|
||||
b"patt" => match parse_pattern_block(data) {
|
||||
Ok(p) => out.patterns.extend(p),
|
||||
Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")),
|
||||
},
|
||||
b"desc" => read_desc(data, out),
|
||||
// Preset hierarchy (groups) and tool presets: not needed for the tips.
|
||||
b"phry" | b"lPdc" => {}
|
||||
k => out.warnings.push(format!("section {} ignored", String::from_utf8_lossy(k))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an `.abr` file.
|
||||
pub fn parse(data: &[u8]) -> Result<AbrFile> {
|
||||
let mut r = Rd::new(data);
|
||||
let version = r.u16()?;
|
||||
let mut out = AbrFile { version, ..Default::default() };
|
||||
match version {
|
||||
1 | 2 => parse_v12(&mut r, version, &mut out)?,
|
||||
6..=10 => parse_v6(&mut r, &mut out)?,
|
||||
v => return Err(PsdError::Unsupported(format!("brush file version {v}"))),
|
||||
}
|
||||
if out.legacy.is_empty() && out.samples.is_empty() && out.presets.is_empty() {
|
||||
let why = out.warnings.first().map(|w| format!(" ({w})")).unwrap_or_default();
|
||||
return Err(PsdError::invalid(format!("the brush file holds no readable brushes{why}")));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ writing
|
||||
|
||||
fn put_u16(o: &mut Vec<u8>, v: u16) {
|
||||
o.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fn put_u32(o: &mut Vec<u8>, v: u32) {
|
||||
o.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
|
||||
fn write_sample_body(o: &mut Vec<u8>, s: &AbrSample, rle: bool) -> Result<()> {
|
||||
if s.width == 0 || s.height == 0 || s.width > MAX_EDGE || s.height > MAX_EDGE || !matches!(s.depth, 8 | 16) {
|
||||
return Err(PsdError::LimitExceeded("brush sample size or depth"));
|
||||
}
|
||||
for v in [0, 0, s.height as i32, s.width as i32] {
|
||||
o.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
put_u16(o, s.depth);
|
||||
o.push(u8::from(rle));
|
||||
let layout = PlaneLayout { planes: 1, width: s.width as usize, height: s.height as usize, depth: s.depth, version: Version::Psd };
|
||||
o.extend_from_slice(&encode_planes(if rle { Compression::Rle } else { Compression::Raw }, &s.data, &layout)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a v1 or v2 file. Sampled tips are PackBits-compressed when `rle`.
|
||||
pub fn write_v12(version: u16, brushes: &[LegacyBrush], rle: bool) -> Result<Vec<u8>> {
|
||||
if !matches!(version, 1 | 2) || brushes.len() > usize::from(u16::MAX) {
|
||||
return Err(PsdError::Unsupported(format!("writing brush file version {version}")));
|
||||
}
|
||||
let mut o = Vec::new();
|
||||
put_u16(&mut o, version);
|
||||
put_u16(&mut o, brushes.len() as u16);
|
||||
for b in brushes {
|
||||
let mut body = Vec::new();
|
||||
put_u32(&mut body, 0);
|
||||
put_u16(&mut body, b.spacing);
|
||||
let ty = match &b.tip {
|
||||
LegacyTip::Computed { diameter, hardness, angle, roundness } => {
|
||||
for v in [*diameter, *roundness, *angle as u16, *hardness] {
|
||||
put_u16(&mut body, v);
|
||||
}
|
||||
1
|
||||
}
|
||||
LegacyTip::Sampled(s) => {
|
||||
if version == 2 {
|
||||
let units: Vec<u16> = b.name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
put_u32(&mut body, units.len() as u32);
|
||||
for u in units {
|
||||
put_u16(&mut body, u);
|
||||
}
|
||||
}
|
||||
body.push(u8::from(b.anti_alias));
|
||||
for v in [0u16, 0, s.height.min(0xffff) as u16, s.width.min(0xffff) as u16] {
|
||||
put_u16(&mut body, v);
|
||||
}
|
||||
write_sample_body(&mut body, s, rle)?;
|
||||
2
|
||||
}
|
||||
};
|
||||
put_u16(&mut o, ty);
|
||||
put_u32(&mut o, body.len() as u32);
|
||||
o.extend_from_slice(&body);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn section(o: &mut Vec<u8>, key: &[u8; 4], data: &[u8]) {
|
||||
o.extend_from_slice(b"8BIM");
|
||||
o.extend_from_slice(key);
|
||||
let pad = (4 - data.len() % 4) % 4;
|
||||
put_u32(o, (data.len() + pad) as u32);
|
||||
o.extend_from_slice(data);
|
||||
o.extend(std::iter::repeat_n(0u8, pad));
|
||||
}
|
||||
|
||||
/// Writes a v6 (`subversion` 1 or 2) file: sampled tips, embedded patterns and the preset
|
||||
/// descriptors (each a `brushPreset` object), listed under `Brsh`.
|
||||
pub fn write_v6(subversion: u16, samples: &[AbrSample], patterns: &[PsdPattern], presets: &[Descriptor], rle: bool) -> Result<Vec<u8>> {
|
||||
if !matches!(subversion, 1 | 2) {
|
||||
return Err(PsdError::Unsupported(format!("brush file subversion {subversion}")));
|
||||
}
|
||||
let mut o = Vec::new();
|
||||
put_u16(&mut o, 6);
|
||||
put_u16(&mut o, subversion);
|
||||
let mut samp = Vec::new();
|
||||
for s in samples {
|
||||
let mut e = Vec::new();
|
||||
let id = s.id.as_bytes();
|
||||
let n = id.len().min(255);
|
||||
e.push(n as u8);
|
||||
e.extend_from_slice(&id[..n]);
|
||||
e.extend(std::iter::repeat_n(0u8, if subversion == 1 { 10 } else { 264 }));
|
||||
write_sample_body(&mut e, s, rle)?;
|
||||
put_u32(&mut samp, e.len() as u32);
|
||||
let pad = (4 - e.len() % 4) % 4;
|
||||
samp.extend_from_slice(&e);
|
||||
samp.extend(std::iter::repeat_n(0u8, pad));
|
||||
}
|
||||
section(&mut o, b"samp", &samp);
|
||||
if !patterns.is_empty() {
|
||||
section(&mut o, b"patt", &write_pattern_block(patterns)?);
|
||||
}
|
||||
let list = Value::List(presets.iter().cloned().map(Value::Descriptor).collect());
|
||||
let desc = VersionedDescriptor::new(Descriptor::new("null").with("Brsh", list));
|
||||
section(&mut o, b"desc", &desc.to_bytes());
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::descriptor::UnicodeString;
|
||||
|
||||
fn sample(id: &str, w: u32, h: u32, depth: u16) -> AbrSample {
|
||||
let bpp = usize::from(depth / 8);
|
||||
let data = (0..w as usize * h as usize * bpp).map(|i| if (i / bpp) % 3 == 0 { 255 } else { (i % 7) as u8 * 30 }).collect();
|
||||
AbrSample { id: id.into(), width: w, height: h, depth, data }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_v2_round_trip_raw_and_rle() {
|
||||
for version in [1u16, 2] {
|
||||
for rle in [false, true] {
|
||||
let brushes = vec![
|
||||
LegacyBrush {
|
||||
name: String::new(),
|
||||
spacing: 25,
|
||||
anti_alias: true,
|
||||
tip: LegacyTip::Computed { diameter: 19, hardness: 80, angle: -30, roundness: 50 },
|
||||
},
|
||||
LegacyBrush {
|
||||
name: if version == 2 { "Leaf ✓".into() } else { String::new() },
|
||||
spacing: 40,
|
||||
anti_alias: false,
|
||||
tip: LegacyTip::Sampled(sample("", 13, 9, 8)),
|
||||
},
|
||||
LegacyBrush { name: String::new(), spacing: 10, anti_alias: true, tip: LegacyTip::Sampled(sample("", 4, 6, 16)) },
|
||||
];
|
||||
let bytes = write_v12(version, &brushes, rle).unwrap();
|
||||
let f = parse(&bytes).unwrap();
|
||||
assert_eq!(f.version, version);
|
||||
assert_eq!(f.legacy, brushes, "v{version} rle={rle}");
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v6_round_trip_both_subversions() {
|
||||
let samples = vec![sample("$abc", 20, 11, 8), sample("$def", 5, 5, 16)];
|
||||
let preset = Descriptor::new("brushPreset")
|
||||
.with("Nm ", Value::Text(UnicodeString::new_nul("Grass")))
|
||||
.with("Brsh", Value::Descriptor(Descriptor::new("sampledBrush").with("sampledData", Value::Text(UnicodeString::new_nul("$abc")))));
|
||||
for sub in [1u16, 2] {
|
||||
for rle in [false, true] {
|
||||
let bytes = write_v6(sub, &samples, &[], std::slice::from_ref(&preset), rle).unwrap();
|
||||
let f = parse(&bytes).unwrap();
|
||||
assert_eq!((f.version, f.subversion), (6, sub));
|
||||
assert_eq!(f.samples, samples);
|
||||
assert_eq!(f.presets, vec![preset.clone()]);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
}
|
||||
}
|
||||
assert_eq!(samples[1].to_unit().len(), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_errors_without_panicking() {
|
||||
assert!(parse(&[]).is_err());
|
||||
assert!(parse(&[0, 3, 0, 0]).is_err());
|
||||
assert!(parse(&[0, 6, 0, 9]).is_err());
|
||||
// Absurd sample bounds.
|
||||
let mut s = sample("", 2, 2, 8);
|
||||
s.width = MAX_EDGE + 1;
|
||||
assert!(write_v12(1, &[LegacyBrush { name: String::new(), spacing: 1, anti_alias: true, tip: LegacyTip::Sampled(s) }], false).is_err());
|
||||
let good = write_v6(2, &[sample("$a", 30, 30, 8)], &[], &[Descriptor::new("brushPreset")], true).unwrap();
|
||||
for cut in 0..good.len() {
|
||||
let _ = parse(&good[..cut]);
|
||||
}
|
||||
let v2 = write_v12(2, &[LegacyBrush { name: "x".into(), spacing: 1, anti_alias: true, tip: LegacyTip::Sampled(sample("", 9, 9, 16)) }], true).unwrap();
|
||||
for cut in 0..v2.len() {
|
||||
let _ = parse(&v2[..cut]);
|
||||
}
|
||||
// Flip every byte once: never panics.
|
||||
for i in 0..good.len() {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0xa5;
|
||||
let _ = parse(&b);
|
||||
}
|
||||
// A huge declared count with no data is an error, not an allocation.
|
||||
assert!(parse(&[0, 2, 0xff, 0xff]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Photoshop gradient files (`.grd`, version 5: Photoshop 6 and later).
|
||||
//!
|
||||
//! Layout from public descriptions of the format: `8BGR`, version u16 (5), then a versioned
|
||||
//! ActionDescriptor whose `GrdL` list holds `Grdn` objects, each with a `Grad` object:
|
||||
//! `Nm ` name, `GrdF` form (`CstS` custom stops or `ClNs` noise), `Clrs` colour stops
|
||||
//! (`Clr ` colour, `Type` user/foreground/background, `Lctn` 0..4096, `Mdpn` midpoint %) and
|
||||
//! `Trns` transparency stops (`Opct` %, `Lctn`, `Mdpn`). Older (version 3) files are rejected.
|
||||
//! Colours are returned in the model they were stored in; converting them is the caller's job.
|
||||
|
||||
use crate::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||
use crate::error::{PsdError, Result};
|
||||
|
||||
/// Most gradients read from one file.
|
||||
pub const MAX_GRADIENTS: usize = 10_000;
|
||||
/// Most stops read per gradient.
|
||||
pub const MAX_STOPS: usize = 1_000;
|
||||
|
||||
/// A stop colour as stored.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum GrdColor {
|
||||
/// 0..1 RGB (from 0..255 components).
|
||||
Rgb([f32; 3]),
|
||||
/// Hue degrees, saturation and brightness 0..1.
|
||||
Hsb([f32; 3]),
|
||||
/// 0..1 C, M, Y, K ink.
|
||||
Cmyk([f32; 4]),
|
||||
/// L 0..100, a, b.
|
||||
Lab([f32; 3]),
|
||||
/// 0..1 ink (0 = white).
|
||||
Gray(f32),
|
||||
/// The live foreground colour.
|
||||
Foreground,
|
||||
/// The live background colour.
|
||||
Background,
|
||||
}
|
||||
|
||||
/// One colour stop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GrdStop {
|
||||
/// 0..1.
|
||||
pub location: f32,
|
||||
/// 0..1 (0.5 = centred).
|
||||
pub midpoint: f32,
|
||||
/// Colour.
|
||||
pub color: GrdColor,
|
||||
}
|
||||
|
||||
/// One gradient.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct GrdGradient {
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// Noise gradient (`ClNs`): stops are absent; callers usually skip or approximate it.
|
||||
pub noise: bool,
|
||||
/// Colour stops, in file order.
|
||||
pub stops: Vec<GrdStop>,
|
||||
/// Transparency stops: (location 0..1, opacity 0..1, midpoint 0..1).
|
||||
pub opacity: Vec<(f32, f32, f32)>,
|
||||
}
|
||||
|
||||
fn num(d: &Descriptor, k: &str) -> Option<f64> {
|
||||
match d.get(k)? {
|
||||
Value::Double(v) => Some(*v),
|
||||
Value::UnitFloat { value, .. } => Some(*value),
|
||||
Value::Integer(v) => Some(f64::from(*v)),
|
||||
Value::LargeInteger(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
fn f(d: &Descriptor, k: &str) -> f32 {
|
||||
num(d, k).unwrap_or(0.0) as f32
|
||||
}
|
||||
fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> {
|
||||
match d.get(k)? {
|
||||
Value::Descriptor(o) | Value::GlobalObject(o) => Some(o),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn list<'a>(d: &'a Descriptor, k: &str) -> &'a [Value] {
|
||||
match d.get(k) {
|
||||
Some(Value::List(v)) => v,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
fn enum_is(d: &Descriptor, k: &str, v: &str) -> bool {
|
||||
matches!(d.get(k), Some(Value::Enumerated { value, .. }) if value.is(v))
|
||||
}
|
||||
|
||||
fn color(c: &Descriptor) -> Option<GrdColor> {
|
||||
let id = c.class_id.as_bytes();
|
||||
Some(match id {
|
||||
b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0]),
|
||||
b"HSBC" => GrdColor::Hsb([f(c, "H "), f(c, "Strt") / 100.0, f(c, "Brgh") / 100.0]),
|
||||
b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0]),
|
||||
b"LbCl" => GrdColor::Lab([f(c, "Lmnc"), f(c, "A "), f(c, "B ")]),
|
||||
b"Grsc" => GrdColor::Gray(f(c, "Gry ") / 100.0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn location(d: &Descriptor) -> f32 {
|
||||
(f(d, "Lctn") / 4096.0).clamp(0.0, 1.0)
|
||||
}
|
||||
fn midpoint(d: &Descriptor) -> f32 {
|
||||
(num(d, "Mdpn").unwrap_or(50.0) as f32 / 100.0).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
let name = match g.get("Nm ") {
|
||||
Some(Value::Text(t)) => t.to_string_lossy(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let mut out = GrdGradient { name, noise: enum_is(g, "GrdF", "ClNs"), ..Default::default() };
|
||||
for v in list(g, "Clrs").iter().take(MAX_STOPS) {
|
||||
let Value::Descriptor(s) = v else { continue };
|
||||
let c = if enum_is(s, "Type", "FrgC") {
|
||||
Some(GrdColor::Foreground)
|
||||
} else if enum_is(s, "Type", "BckC") {
|
||||
Some(GrdColor::Background)
|
||||
} else {
|
||||
obj(s, "Clr ").and_then(color)
|
||||
};
|
||||
if let Some(color) = c {
|
||||
out.stops.push(GrdStop { location: location(s), midpoint: midpoint(s), color });
|
||||
}
|
||||
}
|
||||
for v in list(g, "Trns").iter().take(MAX_STOPS) {
|
||||
let Value::Descriptor(s) = v else { continue };
|
||||
out.opacity.push((location(s), (num(s, "Opct").unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0), midpoint(s)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parses a `.grd` file.
|
||||
pub fn parse(data: &[u8]) -> Result<Vec<GrdGradient>> {
|
||||
let sig = data.get(0..4).ok_or(PsdError::UnexpectedEof { offset: 0, needed: 4 })?;
|
||||
if sig != b"8BGR" {
|
||||
let mut found = [0u8; 4];
|
||||
found.copy_from_slice(sig);
|
||||
return Err(PsdError::InvalidSignature { expected: "8BGR", found });
|
||||
}
|
||||
let version = data.get(4..6).map(|b| u16::from_be_bytes([b[0], b[1]])).ok_or(PsdError::UnexpectedEof { offset: 4, needed: 2 })?;
|
||||
if version != 5 {
|
||||
return Err(PsdError::Unsupported(format!("gradient file version {version} (only version 5, Photoshop 6 and later)")));
|
||||
}
|
||||
let (vd, _) = VersionedDescriptor::parse_prefix(data.get(6..).unwrap_or_default())?;
|
||||
let out: Vec<GrdGradient> = list(&vd.descriptor, "GrdL")
|
||||
.iter()
|
||||
.take(MAX_GRADIENTS)
|
||||
.filter_map(|v| match v {
|
||||
Value::Descriptor(d) => obj(d, "Grad").map(gradient),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if out.is_empty() {
|
||||
return Err(PsdError::invalid("the gradient file holds no gradients"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes a version 5 `.grd` file (RGB / foreground / background stops; used by tests).
|
||||
pub fn write(gradients: &[GrdGradient]) -> Vec<u8> {
|
||||
use crate::descriptor::{Id, UnicodeString};
|
||||
let en = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) };
|
||||
let items = gradients
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let stops = g
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (ty, c) = match s.color {
|
||||
GrdColor::Foreground => ("FrgC", None),
|
||||
GrdColor::Background => ("BckC", None),
|
||||
GrdColor::Rgb([r, gg, b]) => ("UsrS", Some((r, gg, b))),
|
||||
_ => ("UsrS", Some((0.0, 0.0, 0.0))),
|
||||
};
|
||||
let mut d = Descriptor::new("Clrt");
|
||||
if let Some((r, gg, b)) = c {
|
||||
let rgb = Descriptor::new("RGBC")
|
||||
.with("Rd ", Value::Double(f64::from(r) * 255.0))
|
||||
.with("Grn ", Value::Double(f64::from(gg) * 255.0))
|
||||
.with("Bl ", Value::Double(f64::from(b) * 255.0));
|
||||
d = d.with("Clr ", Value::Descriptor(rgb));
|
||||
}
|
||||
Value::Descriptor(
|
||||
d.with("Type", en("Clry", ty))
|
||||
.with("Lctn", Value::Integer((s.location * 4096.0).round() as i32))
|
||||
.with("Mdpn", Value::Integer((s.midpoint * 100.0).round() as i32)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let trns = g
|
||||
.opacity
|
||||
.iter()
|
||||
.map(|&(l, o, m)| {
|
||||
Value::Descriptor(
|
||||
Descriptor::new("TrnS")
|
||||
.with("Opct", Value::UnitFloat { unit: *b"#Prc", value: f64::from(o) * 100.0 })
|
||||
.with("Lctn", Value::Integer((l * 4096.0).round() as i32))
|
||||
.with("Mdpn", Value::Integer((m * 100.0).round() as i32)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let grad = Descriptor::new("Grdn")
|
||||
.with("Nm ", Value::Text(UnicodeString::new_nul(&g.name)))
|
||||
.with("GrdF", en("GrdF", if g.noise { "ClNs" } else { "CstS" }))
|
||||
.with("Intr", Value::Double(4096.0))
|
||||
.with("Clrs", Value::List(stops))
|
||||
.with("Trns", Value::List(trns));
|
||||
Value::Descriptor(Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad)))
|
||||
})
|
||||
.collect();
|
||||
let mut out = b"8BGR".to_vec();
|
||||
out.extend_from_slice(&5u16.to_be_bytes());
|
||||
out.extend_from_slice(&VersionedDescriptor::new(Descriptor::new("null").with("GrdL", Value::List(items))).to_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Vec<GrdGradient> {
|
||||
vec![
|
||||
GrdGradient {
|
||||
name: "Sunset ✓".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Foreground },
|
||||
GrdStop { location: 0.25, midpoint: 0.3, color: GrdColor::Rgb([1.0, 0.5, 0.0]) },
|
||||
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Background },
|
||||
],
|
||||
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.25, 0.5)],
|
||||
},
|
||||
GrdGradient { name: "Noise".into(), noise: true, ..Default::default() },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let b = write(&sample());
|
||||
assert_eq!(parse(&b).unwrap(), sample());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_errors() {
|
||||
assert!(parse(b"").is_err());
|
||||
assert!(parse(b"8BGR\0\x03").is_err());
|
||||
assert!(parse(b"8BPS\0\x05").is_err());
|
||||
let b = write(&sample());
|
||||
for cut in 0..b.len() {
|
||||
let _ = parse(&b[..cut]);
|
||||
}
|
||||
for i in 0..b.len() {
|
||||
let mut c = b.clone();
|
||||
c[i] ^= 0x5a;
|
||||
let _ = parse(&c);
|
||||
}
|
||||
assert!(parse(&write(&[])).is_err());
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,14 @@
|
||||
#![warn(missing_docs)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod abr;
|
||||
pub mod blend;
|
||||
pub mod builder;
|
||||
pub mod compression;
|
||||
pub mod descriptor;
|
||||
pub mod error;
|
||||
pub mod file;
|
||||
pub mod grd;
|
||||
pub mod header;
|
||||
pub mod image_data;
|
||||
mod io;
|
||||
|
||||
@@ -312,12 +312,46 @@ pub fn preview_pixels(b: &BrushSettings, w: u32, h: u32, color: [f32; 4]) -> Vec
|
||||
px.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect()
|
||||
}
|
||||
|
||||
/// Texture for a brush preview, cached on the brush's JSON.
|
||||
/// Cheap signature of everything that changes a brush's preview. Sampled tips and pattern tiles
|
||||
/// contribute their size and a strided sample instead of every pixel, so imported presets with
|
||||
/// big tips don't serialise megabytes per frame.
|
||||
pub fn preview_sig(b: &BrushSettings) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
fn js<T: serde::Serialize>(h: &mut DefaultHasher, v: &T) {
|
||||
serde_json::to_vec(v).unwrap_or_default().hash(h);
|
||||
}
|
||||
fn tile(h: &mut DefaultHasher, t: &paint::GrayTile) {
|
||||
(t.width, t.height).hash(h);
|
||||
let step = (t.data.len() / 4096).max(1);
|
||||
t.data.iter().step_by(step).for_each(|v| v.hash(h));
|
||||
}
|
||||
fn tip(h: &mut DefaultHasher, t: &paint::TipShape) {
|
||||
match t {
|
||||
paint::TipShape::Round => 0u8.hash(h),
|
||||
paint::TipShape::Sampled(g) => tile(h, g),
|
||||
}
|
||||
}
|
||||
let mut h = DefaultHasher::new();
|
||||
js(&mut h, &(b.size, b.hardness, b.spacing, b.opacity, b.flow, b.pressure_size, b.pressure_opacity, b.erase, b.mode, b.angle, b.roundness));
|
||||
js(&mut h, &(b.flip_x, b.flip_y, b.aliased, b.noise, b.wet_edges, b.build_up, b.build_up_rate, b.protect_texture, b.seed));
|
||||
js(&mut h, &(&b.shape_dynamics, &b.scattering, &b.color_dynamics, &b.transfer, &b.pose, &b.smoothing));
|
||||
tip(&mut h, &b.tip);
|
||||
let d = &b.dual_brush;
|
||||
js(&mut h, &(d.enabled, d.mode, d.size, d.hardness, d.roundness, d.angle, d.spacing, d.scatter, d.both_axes, d.count, d.flip));
|
||||
tip(&mut h, &d.tip);
|
||||
let t = &b.texture;
|
||||
js(&mut h, &(t.enabled, t.invert, t.scale, t.brightness, t.contrast, t.each_tip, t.mode, t.depth, t.depth_jitter));
|
||||
match &t.pattern {
|
||||
Pattern::Tile(g) => tile(&mut h, g),
|
||||
p => js(&mut h, p),
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Texture for a brush preview, cached on [`preview_sig`].
|
||||
fn preview_texture(ui: &egui::Ui, b: &BrushSettings, w: u32, h: u32, color: Color32) -> Arc<egui::TextureHandle> {
|
||||
let mut key_b = b.clone();
|
||||
key_b.color = [0.0; 4];
|
||||
let sig = format!("{}{w}x{h}{color:?}", serde_json::to_string(&key_b).unwrap_or_default());
|
||||
let id = egui::Id::new(("brush-preview", sig));
|
||||
let id = egui::Id::new(("brush-preview", preview_sig(b), w, h, color));
|
||||
if let Some(tex) = ui.data(|d| d.get_temp::<Arc<egui::TextureHandle>>(id)) {
|
||||
return tex;
|
||||
}
|
||||
@@ -329,32 +363,81 @@ fn preview_texture(ui: &egui::Ui, b: &BrushSettings, w: u32, h: u32, color: Colo
|
||||
tex
|
||||
}
|
||||
|
||||
/// Group label for presets saved without a group.
|
||||
pub const UNGROUPED: &str = "My Brushes";
|
||||
|
||||
/// Preset indices in panel order: groups in order of first appearance.
|
||||
pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize>)> {
|
||||
let mut out: Vec<(String, Vec<usize>)> = Vec::new();
|
||||
for (i, p) in presets.iter().enumerate() {
|
||||
let g = if p.group.is_empty() { UNGROUPED } else { p.group.as_str() };
|
||||
match out.iter_mut().find(|(n, _)| n == g) {
|
||||
Some((_, v)) => v.push(i),
|
||||
None => out.push((g.to_string(), vec![i])),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn presets_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let presets: Vec<(String, BrushSettings)> = app.session.tools.presets.iter().map(|p| (p.name.clone(), p.brush.clone())).collect();
|
||||
// The current brush matches a preset when everything but its colour and size agrees.
|
||||
let same = |a: &BrushSettings, b: &BrushSettings| BrushSettings { color: b.color, size: b.size, background: b.background, ..a.clone() } == *b;
|
||||
let current = app.session.tools.brush.clone();
|
||||
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (name, brush) in presets {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click());
|
||||
if same(&brush, ¤t) {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Import Brushes…").on_hover_text("Load Photoshop brushes (.abr)").clicked() {
|
||||
app.open_dialog_file();
|
||||
}
|
||||
let tex = preview_texture(ui, &brush, 180, 36, t.text);
|
||||
let ir = egui::Rect::from_min_size(r.left_top() + vec2(4.0, 2.0), vec2(180.0, 36.0));
|
||||
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||||
ui.painter().text(egui::pos2(ir.right() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &name, egui::FontId::proportional(12.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let r = app.session.execute("tools.setBrush", json!({"preset": name}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let groups = grouped_presets(&app.session.tools.presets);
|
||||
// The current brush matches a preset when everything but its colour and size agrees.
|
||||
// Cheap fields first: the full comparison clones the preset (and its tip).
|
||||
let same = |a: &BrushSettings, b: &BrushSettings| {
|
||||
a.hardness == b.hardness
|
||||
&& a.spacing == b.spacing
|
||||
&& a.tip == b.tip
|
||||
&& BrushSettings { color: b.color, size: b.size, background: b.background, ..a.clone() } == *b
|
||||
};
|
||||
let brush = &app.session.tools.brush;
|
||||
let mut clicked = None;
|
||||
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (group, items) in groups {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(&group).font(theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(2.0);
|
||||
for i in items {
|
||||
let Some(p) = app.session.tools.presets.get(i) else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click());
|
||||
if !ui.is_rect_visible(r) {
|
||||
continue;
|
||||
}
|
||||
if same(&p.brush, brush) {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let tex = preview_texture(ui, &p.brush, 180, 36, t.text);
|
||||
let ir = egui::Rect::from_min_size(r.left_top() + vec2(4.0, 2.0), vec2(180.0, 36.0));
|
||||
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
egui::pos2(ir.right() + 10.0, r.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&p.name,
|
||||
egui::FontId::proportional(12.0),
|
||||
t.text_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(p.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(name) = clicked
|
||||
&& let Err(e) = app.session.execute("tools.setBrush", json!({"preset": name}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -942,6 +942,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
}
|
||||
}
|
||||
|
||||
// Pen pressure/tilt for this frame's tool events (mouse = 1.0).
|
||||
app.stylus.update(&ui.input(|i| i.events.clone()));
|
||||
let space_pan = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
let middle = ui.input(|i| i.pointer.middle_down());
|
||||
let tool = if space_pan || middle { Tool::Hand } else { app.ui.tool };
|
||||
@@ -965,13 +967,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
let d = xf.to_doc(p);
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, mods);
|
||||
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, mods);
|
||||
}
|
||||
if response.dragged()
|
||||
&& let Some(p) = response.interact_pointer_pos()
|
||||
{
|
||||
let d = xf.to_doc(p);
|
||||
tool_event(app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, mods);
|
||||
tool_event(app, ToolEvent::Move { x: d[0], y: d[1], pressure: app.stylus.pressure() }, mods);
|
||||
}
|
||||
if response.drag_stopped() {
|
||||
let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]])));
|
||||
@@ -1435,6 +1437,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
_ => {}
|
||||
}
|
||||
app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods });
|
||||
app.stylus.begin_stroke();
|
||||
}
|
||||
ToolEvent::Move { x, y, pressure } => {
|
||||
if tool == Tool::Type && app.drag.is_none() {
|
||||
@@ -1447,6 +1450,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
&& d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25)
|
||||
{
|
||||
d.points.push([x, y, pressure as f64]);
|
||||
app.stylus.record_point();
|
||||
}
|
||||
}
|
||||
ToolEvent::Up { x, y } => {
|
||||
@@ -1461,6 +1465,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
let Some(mut d) = app.drag.take() else { return };
|
||||
if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) {
|
||||
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
|
||||
app.stylus.record_point();
|
||||
}
|
||||
finish_gesture(app, d);
|
||||
}
|
||||
@@ -1478,7 +1483,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]),
|
||||
Tool::Type => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]),
|
||||
Tool::Brush | Tool::Eraser => {
|
||||
let pts: Vec<[f64; 3]> = d.points.clone();
|
||||
let pts = app.stylus.stroke_points(&d.points);
|
||||
let _ = app.run("paint.stroke", json!({ "points": pts, "erase": d.tool == Tool::Eraser, "smoothing": 0.3, "target": paint_target(app) }));
|
||||
}
|
||||
Tool::RectMarquee | Tool::EllipseMarquee => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! - `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree
|
||||
//! - `ui.dialog.open {kind, fields?}` (kinds: newDocument, about, layerStyle {effect?}, colorPicker {target: foreground|background}, command {command}) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}`
|
||||
//! - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?}], modifiers?}`: drive the active tool in document coordinates
|
||||
//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates
|
||||
//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points
|
||||
//! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`: synthetic keyboard input
|
||||
//! - `ui.resize {width, height}`: resize the main window
|
||||
@@ -235,8 +235,16 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
"up" => ToolEvent::Up { x, y },
|
||||
_ => ToolEvent::Move { x, y, pressure: pr },
|
||||
};
|
||||
// A simulated pen: tilt/rotation reach the stroke like a real stylus's (see `stylus`).
|
||||
let tilt = |k: &str| e.get(k).and_then(Value::as_f64).map(|v| v as f32);
|
||||
let pen = (tilt("tiltX"), tilt("tiltY"), tilt("rotation"));
|
||||
if pen != (None, None, None) {
|
||||
let (tilt_x, tilt_y, rotation) = (pen.0.unwrap_or(0.0), pen.1.unwrap_or(0.0), pen.2.unwrap_or(0.0));
|
||||
app.stylus.feed.set(Some(crate::stylus::PenSample { pressure: pr, tilt_x, tilt_y, rotation }));
|
||||
}
|
||||
tool_event(app, ev, mods);
|
||||
}
|
||||
app.stylus.feed.set(None);
|
||||
ok(json!({"status": app.ui.status}))
|
||||
}
|
||||
"ui.click" | "ui.move" => {
|
||||
|
||||
@@ -27,6 +27,10 @@ impl PhotocraftApp {
|
||||
/// `path` for File › Save, and `path` goes to the top of Open Recent. Returns the import
|
||||
/// warnings (also shown to the user).
|
||||
pub fn open_file(&mut self, path: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
// Brushes/gradients go to the preset libraries (no document, no Open Recent entry).
|
||||
if let Some(r) = crate::preset_files_ui::open(self, path, bytes) {
|
||||
return r.map(|()| Vec::new());
|
||||
}
|
||||
let warnings = self.open_bytes(&display_name(path), bytes)?;
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.path = Some(path.to_string());
|
||||
|
||||
@@ -52,6 +52,7 @@ pub mod parity;
|
||||
pub mod perspective_ui;
|
||||
pub mod plugin_ui;
|
||||
pub mod prefs_ui;
|
||||
pub mod preset_files_ui;
|
||||
pub mod preset_panels;
|
||||
pub mod proxy;
|
||||
pub mod puppet_ui;
|
||||
@@ -63,6 +64,7 @@ pub mod slice_ui;
|
||||
pub mod smart_ui;
|
||||
pub mod snap_ui;
|
||||
pub mod state;
|
||||
pub mod stylus;
|
||||
pub mod theme;
|
||||
mod timeline_ui;
|
||||
pub mod tone;
|
||||
@@ -240,6 +242,8 @@ pub struct PhotocraftApp {
|
||||
pub(crate) discard: Option<discard_ui::Prompt>,
|
||||
/// Set once the user has agreed to quit, so the resulting close request goes through.
|
||||
pub(crate) allow_close: bool,
|
||||
/// Pen pressure/tilt from the platform (see `stylus`).
|
||||
pub stylus: stylus::Stylus,
|
||||
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
||||
live_tokens: theme::live::LiveTokens,
|
||||
}
|
||||
@@ -291,6 +295,7 @@ impl PhotocraftApp {
|
||||
prefs_rt: Default::default(),
|
||||
discard: None,
|
||||
allow_close: false,
|
||||
stylus: Default::default(),
|
||||
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
||||
live_tokens: theme::live::LiveTokens::from_env(),
|
||||
};
|
||||
@@ -362,8 +367,11 @@ impl PhotocraftApp {
|
||||
|
||||
/// Open a file's bytes as a new document named `name`; returns the import warnings (also
|
||||
/// shown to the user). Files from disk go through [`open_file`](Self::open_file), which also
|
||||
/// remembers the path.
|
||||
/// remembers the path. Brush and gradient files go to the preset libraries instead.
|
||||
pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
if let Some(r) = preset_files_ui::open(self, name, bytes) {
|
||||
return r.map(|()| Vec::new());
|
||||
}
|
||||
let import = self.services.import.as_ref().ok_or("no importer configured")?;
|
||||
let (doc, warnings) = import(name, bytes)?;
|
||||
// Edit › Color Settings policies apply on open; mismatches can ask what to do.
|
||||
|
||||
@@ -901,6 +901,10 @@ fn presets_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String,
|
||||
if ui.add_enabled(selected < list.len(), egui::Button::new("Delete")).clicked() {
|
||||
let _ = app.run("edit.presets.presetManager", json!({"action": "delete", "kind": kind, "index": selected}));
|
||||
}
|
||||
// Load Photoshop brushes (.abr) into the library.
|
||||
if kind == "brushes" && ui.button("Load…").on_hover_text("Import Photoshop brushes (.abr)").clicked() {
|
||||
app.open_dialog_file();
|
||||
}
|
||||
});
|
||||
f.insert("kind".into(), json!(kind));
|
||||
f.insert("selected".into(), json!(selected));
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Preset files opened like documents (File › Open, drag-and-drop, the Brushes panel's
|
||||
//! Import Brushes…, the Preset Manager's Load…): Photoshop brushes (`.abr`) go to the brush
|
||||
//! library through `brush.presets.importAbr`, gradients (`.grd`) to the Gradients panel through
|
||||
//! `gradient.presets.importGrd`, instead of opening as documents.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Extensions handled here rather than by the document importer.
|
||||
pub const PRESET_EXTS: &[&str] = &["abr", "grd"];
|
||||
|
||||
fn ext(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Is `name` a preset file this module imports?
|
||||
pub fn is_preset_file(name: &str) -> bool {
|
||||
PRESET_EXTS.contains(&ext(name).as_str())
|
||||
}
|
||||
|
||||
/// Command params for the file: a real path keeps the journal small; browser files arrive as bytes.
|
||||
fn source(name: &str, bytes: &[u8]) -> Value {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::path::Path::new(name).is_absolute() && std::path::Path::new(name).is_file() {
|
||||
return json!({"path": name});
|
||||
}
|
||||
let _ = name;
|
||||
json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})
|
||||
}
|
||||
|
||||
/// Import a preset file; `None` when `name` isn't one.
|
||||
pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option<Result<(), String>> {
|
||||
let (cmd, what) = match ext(name).as_str() {
|
||||
"abr" => ("brush.presets.importAbr", "brushes"),
|
||||
"grd" => ("gradient.presets.importGrd", "gradients"),
|
||||
_ => return None,
|
||||
};
|
||||
let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Imported".into());
|
||||
let mut p = source(name, bytes);
|
||||
p["group"] = json!(stem);
|
||||
if what == "brushes" {
|
||||
p["select"] = json!(true);
|
||||
}
|
||||
Some(app.run(cmd, p).map(|r| {
|
||||
let n = r.get("count").and_then(Value::as_u64).unwrap_or(0);
|
||||
let warnings: Vec<String> = r.get("warnings").and_then(|w| serde_json::from_value(w.clone()).ok()).unwrap_or_default();
|
||||
app.ui.status_error = false;
|
||||
app.ui.status = match warnings.first() {
|
||||
Some(w) => format!("Imported {n} {what} from {stem} ({} notes: {w})", warnings.len()),
|
||||
None => format!("Imported {n} {what} from {stem}"),
|
||||
};
|
||||
if what == "brushes" {
|
||||
// Show them: Brush Settings › Brushes tab.
|
||||
app.ui.panels.brush_settings = true;
|
||||
app.ui.brush_tab = 1;
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognises_preset_files() {
|
||||
assert!(is_preset_file("/a/b/Set.ABR"));
|
||||
assert!(is_preset_file("sunsets.grd"));
|
||||
assert!(!is_preset_file("photo.psd"));
|
||||
assert!(!is_preset_file("abr"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Stylus input: real pen pressure (and tilt / barrel rotation where the platform reports them)
|
||||
//! for the painting tools. A mouse always paints at pressure 1 with no tilt.
|
||||
//!
|
||||
//! Sources, by platform (eframe 0.36.2, egui-winit 0.36.2, winit 0.30.13):
|
||||
//!
|
||||
//! - **Windows**: winit turns `WM_POINTER` pen and touch input into `WindowEvent::Touch` with a
|
||||
//! normalised force (`POINTER_PEN_INFO::pressure / 1024`); egui-winit forwards it as
|
||||
//! `egui::Event::Touch { force }` alongside the emulated pointer. [`Stylus::update`] reads it.
|
||||
//! winit drops the pen's tilt and rotation, so those stay 0.
|
||||
//! - **Web**: eframe forwards touch force but not pen pointer events, so the web runner listens
|
||||
//! to `pointerdown`/`pointermove` itself and writes `pressure`, `tiltX`, `tiltY` and `twist`
|
||||
//! of `pointerType == "pen"` events into the [`StylusFeed`].
|
||||
//! - **macOS**: winit 0.30 reports no tablet pressure: the `NSEvent.pressure` of mouse-dragged
|
||||
//! tablet events is discarded, and `pressureChangeWithEvent:` becomes
|
||||
//! `WindowEvent::TouchpadPressure`, which egui-winit 0.36 ignores. Strokes paint at pressure 1
|
||||
//! until winit/egui-winit forward it (no unsafe AppKit hooks here: the workspace forbids
|
||||
//! `unsafe`).
|
||||
//! - **Linux (X11/Wayland)**: winit 0.30 has no tablet (XInput2 valuator / tablet-v2) support;
|
||||
//! pressure is 1.
|
||||
//!
|
||||
//! Automation simulates a pen with `ui.pointer` events carrying `pressure`, `tiltX`, `tiltY`
|
||||
//! and `rotation`.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// One stylus reading. Tilt is in degrees (-90..90, W3C Pointer Events convention), rotation in
|
||||
/// degrees 0..360.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PenSample {
|
||||
pub pressure: f32,
|
||||
pub tilt_x: f32,
|
||||
pub tilt_y: f32,
|
||||
pub rotation: f32,
|
||||
}
|
||||
|
||||
impl Default for PenSample {
|
||||
fn default() -> Self {
|
||||
Self { pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl PenSample {
|
||||
/// Clamp to the documented ranges (and replace non-finite values).
|
||||
pub fn sanitized(self) -> Self {
|
||||
let f = |v: f32, lo: f32, hi: f32, d: f32| if v.is_finite() { v.clamp(lo, hi) } else { d };
|
||||
Self {
|
||||
pressure: f(self.pressure, 0.0, 1.0, 1.0),
|
||||
tilt_x: f(self.tilt_x, -90.0, 90.0, 0.0),
|
||||
tilt_y: f(self.tilt_y, -90.0, 90.0, 0.0),
|
||||
rotation: if self.rotation.is_finite() { self.rotation.rem_euclid(360.0) } else { 0.0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared slot a platform backend writes the current pen sample into (`None` = no pen down).
|
||||
/// Cloning shares the slot.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct StylusFeed(Arc<Mutex<Option<PenSample>>>);
|
||||
|
||||
impl StylusFeed {
|
||||
pub fn set(&self, s: Option<PenSample>) {
|
||||
if let Ok(mut g) = self.0.lock() {
|
||||
*g = s.map(PenSample::sanitized);
|
||||
}
|
||||
}
|
||||
pub fn get(&self) -> Option<PenSample> {
|
||||
self.0.lock().ok().and_then(|g| *g)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-app stylus state.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Stylus {
|
||||
/// Pen samples pushed by the platform (web runner) or by automation.
|
||||
pub feed: StylusFeed,
|
||||
/// Force of the touch/pen contact currently down (egui `Event::Touch`).
|
||||
touch: Option<f32>,
|
||||
/// The contact ended this frame: keep its force for this frame's last tool events, clear next frame.
|
||||
lifted: bool,
|
||||
/// Tilt X, tilt Y, rotation of each point of the current drag (parallel to its points).
|
||||
pub(crate) stroke: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl Stylus {
|
||||
/// Track touch/pen contacts from this frame's input events.
|
||||
pub fn update(&mut self, events: &[egui::Event]) {
|
||||
if std::mem::take(&mut self.lifted) {
|
||||
self.touch = None;
|
||||
}
|
||||
for e in events {
|
||||
if let egui::Event::Touch { phase, force, .. } = e {
|
||||
match phase {
|
||||
egui::TouchPhase::Start | egui::TouchPhase::Move => {
|
||||
if let Some(f) = force.filter(|f| f.is_finite()) {
|
||||
self.touch = Some(f.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
egui::TouchPhase::End | egui::TouchPhase::Cancel => self.lifted = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current pen sample, `None` for a mouse.
|
||||
pub fn sample(&self) -> Option<PenSample> {
|
||||
self.feed.get().or(self.touch.map(|pressure| PenSample { pressure, ..Default::default() }))
|
||||
}
|
||||
|
||||
/// Pressure for the next tool event (1 for a mouse).
|
||||
pub fn pressure(&self) -> f32 {
|
||||
self.sample().map_or(1.0, |s| s.pressure)
|
||||
}
|
||||
|
||||
/// Start recording a drag's tilt/rotation.
|
||||
pub(crate) fn begin_stroke(&mut self) {
|
||||
self.stroke.clear();
|
||||
self.record_point();
|
||||
}
|
||||
|
||||
/// Record the tilt/rotation of a point just added to the drag.
|
||||
pub(crate) fn record_point(&mut self) {
|
||||
let s = self.sample().unwrap_or_default();
|
||||
self.stroke.push([s.tilt_x, s.tilt_y, s.rotation]);
|
||||
}
|
||||
|
||||
/// Stroke points for `paint.stroke`: `[x, y, pressure]`, extended with
|
||||
/// `tiltX, tiltY, rotation` when the drag carried any tilt or rotation.
|
||||
pub fn stroke_points(&self, points: &[[f64; 3]]) -> Vec<Vec<f64>> {
|
||||
let has_pose = self.stroke.iter().any(|t| t.iter().any(|v| *v != 0.0));
|
||||
points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let mut v = vec![p[0], p[1], p[2]];
|
||||
if has_pose {
|
||||
let t = self.stroke.get(i).or(self.stroke.last()).copied().unwrap_or_default();
|
||||
v.extend(t.map(f64::from));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn touch(phase: egui::TouchPhase, force: Option<f32>) -> egui::Event {
|
||||
egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(0), phase, pos: egui::pos2(1.0, 1.0), force }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_is_full_pressure() {
|
||||
let mut s = Stylus::default();
|
||||
s.update(&[egui::Event::PointerMoved(egui::pos2(3.0, 4.0))]);
|
||||
assert_eq!(s.pressure(), 1.0);
|
||||
assert_eq!(s.sample(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_force_drives_pressure_until_lift() {
|
||||
let mut s = Stylus::default();
|
||||
s.update(&[touch(egui::TouchPhase::Start, Some(0.25))]);
|
||||
assert_eq!(s.pressure(), 0.25);
|
||||
s.update(&[touch(egui::TouchPhase::Move, Some(0.8)), touch(egui::TouchPhase::Move, Some(1.7))]);
|
||||
assert_eq!(s.pressure(), 1.0, "clamped");
|
||||
s.update(&[touch(egui::TouchPhase::Move, Some(f32::NAN))]);
|
||||
assert_eq!(s.pressure(), 1.0);
|
||||
s.update(&[touch(egui::TouchPhase::Move, Some(0.4))]);
|
||||
assert_eq!(s.pressure(), 0.4);
|
||||
// The lift frame still paints at the last force; the next frame is back to the mouse.
|
||||
s.update(&[touch(egui::TouchPhase::End, None)]);
|
||||
assert_eq!(s.pressure(), 0.4);
|
||||
s.update(&[]);
|
||||
assert_eq!(s.pressure(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_wins_and_is_sanitized() {
|
||||
let mut s = Stylus::default();
|
||||
s.update(&[touch(egui::TouchPhase::Start, Some(0.3))]);
|
||||
s.feed.set(Some(PenSample { pressure: 0.6, tilt_x: 120.0, tilt_y: -30.0, rotation: -90.0 }));
|
||||
assert_eq!(s.sample(), Some(PenSample { pressure: 0.6, tilt_x: 90.0, tilt_y: -30.0, rotation: 270.0 }));
|
||||
s.feed.set(None);
|
||||
assert_eq!(s.pressure(), 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_samples_reach_the_brush_stroke() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
use serde_json::json;
|
||||
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", json!({"width": 120, "height": 80})).unwrap();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
app.ui.tool = crate::state::Tool::Brush;
|
||||
let m = egui::Modifiers::NONE;
|
||||
app.stylus.feed.set(Some(PenSample { pressure: 0.2, tilt_x: 40.0, tilt_y: 0.0, rotation: 30.0 }));
|
||||
let pr = app.stylus.pressure();
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: pr }, m);
|
||||
app.stylus.feed.set(Some(PenSample { pressure: 0.9, tilt_x: 10.0, tilt_y: -5.0, rotation: 60.0 }));
|
||||
let pr = app.stylus.pressure();
|
||||
tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 40.0, pressure: pr }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 40.0 }, m);
|
||||
let (id, p) = app.session.journal.iter().rev().find(|(id, _)| id == "paint.stroke").cloned().unwrap();
|
||||
assert_eq!(id, "paint.stroke");
|
||||
assert_eq!(p["points"][0], json!([10.0, 40.0, 0.2f32 as f64, 40.0, 0.0, 30.0]));
|
||||
assert_eq!(p["points"][1], json!([100.0, 40.0, 0.9f32 as f64, 10.0, -5.0, 60.0]));
|
||||
// A mouse stroke stays at full pressure with plain [x, y, p] points.
|
||||
app.stylus.feed.set(None);
|
||||
let pr = app.stylus.pressure();
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 60.0, pressure: pr }, m);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 90.0, y: 60.0 }, m);
|
||||
let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == "paint.stroke").cloned().unwrap();
|
||||
assert_eq!(p["points"][0], json!([10.0, 60.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_points_carry_tilt_only_when_present() {
|
||||
let mut s = Stylus::default();
|
||||
let pts = [[0.0, 0.0, 0.5], [10.0, 0.0, 0.7], [20.0, 0.0, 0.7]];
|
||||
s.begin_stroke();
|
||||
s.record_point();
|
||||
assert_eq!(s.stroke_points(&pts)[1], vec![10.0, 0.0, 0.7]);
|
||||
s.feed.set(Some(PenSample { pressure: 0.7, tilt_x: 30.0, tilt_y: 10.0, rotation: 45.0 }));
|
||||
s.record_point();
|
||||
let out = s.stroke_points(&pts);
|
||||
assert_eq!(out[0], vec![0.0, 0.0, 0.5, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(out[2], vec![20.0, 0.0, 0.7, 30.0, 10.0, 45.0]);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree
|
||||
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}`
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?}], modifiers?}`: drive the active tool in document coordinates
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen)
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
|
||||
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
|
||||
- `ui.resize {width, height}`: resize the main window
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ remaining Opus 5.5 effort (~25–40 wall-clock hours to a 95%, ships-for-most st
|
||||
| M3 Viewer app | ✅ | egui shell (Pro / Studio / Classic themes), 13+ codecs, native and web (trunk) builds |
|
||||
| M4 Native format + engine | ✅ | 500+ commands, `.pcraft` (incremental, autosave, crash recovery), CLI, persistent preferences |
|
||||
| M5 GPU compositor | 🟡 | wgpu compositor drives the canvas, layer effects, vector masks, artboards, pattern fills and every clip case included (≤1/255 vs CPU); Multichannel documents fall back to the CPU |
|
||||
| M6 Paint + select | 🟡 | brush engine, all selection tools, multi-layer selection, snapping + smart guides, free transform + warp; native tablet backends pending |
|
||||
| M6 Paint + select | 🟡 | brush engine, all selection tools, multi-layer selection, snapping + smart guides, free transform + warp; stylus pressure on Windows (WM_POINTER via winit) and the web (Pointer Events, with tilt/twist); macOS and Linux wait on winit tablet support |
|
||||
| M7 Adjust + filters | 🟡 | 16 adjustment layers + destructive-only adjustments, 70+ filters incl. Blur Gallery, Actions record/replay, Fade |
|
||||
| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle 111/170 |
|
||||
| M9 Text, vector, styles | 🟡 | type engine + Warp Text, shapes / pen / paths, all 10 effects on CPU and GPU (parity ≤1/255, 30/31 corpus effect files on the GPU) |
|
||||
|
||||
Reference in new issue
Block a user