Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 d15f045a2e ui: real stylus pressure/tilt, grouped Brushes panel, Import Brushes
Pen force from egui Touch events (Windows WM_POINTER via winit), Pointer Events
pressure/tilt/twist on the web, tilt via ui.pointer; mouse stays 1.0. .abr/.grd
open from File > Open, drag-and-drop, Brushes panel and Preset Manager. Preset
previews keyed on a cheap signature instead of the full brush JSON.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:26:08 -04:00
Brandon ThomasandClaude Opus 5.5 13ba3da4b4 engine: brush.presets.importAbr and gradient.presets.importGrd
Path or base64 data, group per file (re-import replaces), unique names next to
the built-ins; graceful-failure tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:26:08 -04:00
Brandon ThomasandClaude Opus 5.5 80d30e8f58 io: map .abr brushes onto BrushSettings
Tip shape, shape dynamics, scattering, texture (embedded patt patterns),
dual brush, colour dynamics, transfer, pose, noise, wet edges, build-up and
captured tool options; unmapped settings are reported as warnings. Includes
bench_abr (100-brush import, 5000-dab stroke).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:26:08 -04:00
Brandon ThomasandClaude Opus 5.5 b169a718e1 paint: 36 grouped built-in brush presets and new procedural tips
General / Dry Media / Wet Media / Special Effects groups (pressure size/opacity
variants, airbrush, pencils, charcoal, pastel, crayon, rake, watercolour, ink,
oil, foliage, grass, stipple, sparkle, halftone…). Leaf, grass, charcoal,
sponge, star and rake tips are generated in code. BrushPreset gains a group.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:26:08 -04:00
Brandon ThomasandClaude Opus 5.5 a4fdcd92c7 psd: .abr brush (v1/v2/v6+) and .grd gradient (v5) parsers and writers
Clean-room from public format descriptions: legacy computed/sampled brushes,
8BIM-sectioned samp/patt/desc files, 8/16-bit raw and PackBits tips. Bounded
(MAX_EDGE, MAX_TOTAL_BYTES), damaged brushes are skipped with warnings, hostile
input returns Err. Adds an abr fuzz target.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 16:26:08 -04:00
33 changed files with 3057 additions and 98 deletions

No files matched your search

Generated
+1
View File
@@ -3479,6 +3479,7 @@ dependencies = [
"photocraft-doc",
"photocraft-format",
"photocraft-geom",
"photocraft-paint",
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
+1 -1
View File
@@ -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"] }
+27 -2
View File
@@ -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()
}
+3 -2
View File
@@ -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
+1
View File
@@ -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"] }
+2 -2
View File
@@ -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 }))
}
+1
View File
@@ -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());
+1
View File
@@ -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;
+340
View File
@@ -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");
}
}
+1
View File
@@ -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 }
+123
View File
@@ -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);
}
+531
View File
@@ -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;
+234
View File
@@ -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());
}
+1
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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));
}
}
+122 -1
View File
@@ -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}");
+4 -2
View File
@@ -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',
+7
View File
@@ -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
+14
View File
@@ -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));
}
}
});
+560
View File
@@ -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());
}
}
+263
View File
@@ -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());
}
}
+2
View File
@@ -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;
+107 -24
View File
@@ -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, &current) {
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) {
+8 -3
View File
@@ -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 => {
+9 -1
View File
@@ -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" => {
+4
View File
@@ -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());
+9 -1
View File
@@ -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.
+4
View File
@@ -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));
+72
View File
@@ -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"));
}
}
+231
View File
@@ -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]);
}
}
+1 -1
View File
@@ -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
View File
@@ -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) |