mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 15:14:11 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ba40f2ea |
No files matched your search
+1
-1
@@ -12,7 +12,7 @@ next to them.
|
||||
|---|---|---|---|---|
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -222,17 +222,37 @@ fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pt
|
||||
let mut brush = brush;
|
||||
erase_locked(&mut brush, lock, bg);
|
||||
if auto_erase {
|
||||
// Pencil Auto Erase: starting on foreground-coloured pixels paints the background colour.
|
||||
let c = surf.rgba(pts[0].x.floor() as i32, pts[0].y.floor() as i32);
|
||||
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
|
||||
brush.color = bg;
|
||||
}
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
|
||||
}
|
||||
Ok(render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom))
|
||||
})?;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
|
||||
/// Pencil Auto Erase: a stroke that starts on a pixel of the foreground colour paints the
|
||||
/// background colour instead (Photoshop).
|
||||
fn apply_auto_erase(brush: &mut BrushSettings, surf: &Surface, start: Option<&StrokePoint>, fg: [f32; 4], bg: [f32; 4]) {
|
||||
let Some(p0) = start else { return };
|
||||
let c = surf.rgba(p0.x.floor() as i32, p0.y.floor() as i32);
|
||||
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
|
||||
brush.color = bg;
|
||||
}
|
||||
}
|
||||
|
||||
/// The Pencil's brush: the session brush made aliased (every pixel fully painted or untouched,
|
||||
/// dabs on the pixel grid), hard and at full flow, with the options-bar mode.
|
||||
fn pencil_brush(s: &Session, p: &Value) -> Result<BrushSettings> {
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
brush.aliased = true;
|
||||
if num(p, "hardness").is_none() {
|
||||
brush.hardness = 1.0;
|
||||
}
|
||||
if num(p, "flow").is_none() {
|
||||
brush.flow = 1.0;
|
||||
}
|
||||
Ok(brush)
|
||||
}
|
||||
|
||||
/// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name
|
||||
/// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal.
|
||||
fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings {
|
||||
@@ -273,15 +293,29 @@ pub struct LiveStroke {
|
||||
impl LiveStroke {
|
||||
/// Start from `paint.stroke` params; their `points` are rendered.
|
||||
pub fn begin(s: &Session, p: &Value) -> Result<Self> {
|
||||
Self::begin_with(s, "paint.stroke", p)
|
||||
}
|
||||
|
||||
/// Start a live stroke of `cmd`: `paint.stroke` (the Brush and Eraser) or `paint.pencil`.
|
||||
/// Committing `cmd` with the same params and `"seed": live.seed` gives the same pixels.
|
||||
pub fn begin_with(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
|
||||
has_paintable(s).map_err(EngineError::Other)?;
|
||||
let pts = parse_points(p, "paint.stroke")?;
|
||||
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
|
||||
let seed = brush.seed;
|
||||
let pts = parse_points(p, cmd)?;
|
||||
let pencil = match cmd {
|
||||
"paint.stroke" => false,
|
||||
"paint.pencil" => true,
|
||||
other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")),
|
||||
};
|
||||
let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) };
|
||||
let (seed, fg) = (brush.seed, brush.color);
|
||||
let (layer, mut brush, zoom) = stroke_target(s, p, brush)?;
|
||||
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
|
||||
erase_locked(&mut brush, lock, s.tools.background);
|
||||
if pencil && flag(p, "autoErase", false) {
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background);
|
||||
}
|
||||
let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
let pre = surf.clone();
|
||||
let mut live = Self { doc: std::sync::Arc::new(doc), seed, renderer, pre, sel, lock, layer, params: p.clone(), tail: Rect::EMPTY };
|
||||
@@ -320,11 +354,7 @@ impl LiveStroke {
|
||||
|
||||
fn pencil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = parse_points(p, "paint.pencil")?;
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
brush.aliased = true;
|
||||
if num(p, "hardness").is_none() {
|
||||
brush.hardness = 1.0;
|
||||
}
|
||||
let brush = pencil_brush(s, p)?;
|
||||
let label = if brush.erase { "Eraser" } else { "Pencil" };
|
||||
stroke_with(s, p, label, brush, pts, flag(p, "autoErase", false))
|
||||
}
|
||||
|
||||
@@ -312,6 +312,77 @@ fn live_stroke_matches_the_committed_stroke() {
|
||||
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_pencil_matches_the_committed_pencil_and_auto_erases() {
|
||||
let mut s = session(80, 40);
|
||||
s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
|
||||
// A soft, low-flow session brush: the Pencil is still hard, aliased and full flow.
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "flow": 0.3}})).unwrap();
|
||||
let pts = [[5.0, 5.0, 1.0], [30.2, 17.7, 0.6], [70.0, 33.0, 1.0]];
|
||||
for (auto, start) in [(false, pts[0]), (true, [30.4, 17.2, 1.0])] {
|
||||
let p = json!({"points": [start], "autoErase": auto, "smoothing": 0.0});
|
||||
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
|
||||
let rest: Vec<StrokePoint> = pts.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
|
||||
live.push(&rest).unwrap();
|
||||
let mut commit = p.clone();
|
||||
let mut all = vec![start];
|
||||
all.extend(pts);
|
||||
commit["points"] = json!(all);
|
||||
commit["seed"] = json!(live.seed);
|
||||
s.execute("paint.pencil", commit).unwrap();
|
||||
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 80, 40)), "auto erase {auto}");
|
||||
for y in 0..40 {
|
||||
for x in 0..80 {
|
||||
let a = shown.rgba(x, y)[3];
|
||||
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The second stroke started on the first one's black: Auto Erase painted white.
|
||||
assert_eq!(rgba(&s, 70, 33), [1.0, 1.0, 1.0, 1.0]);
|
||||
// Only the Brush and the Pencil stroke live.
|
||||
assert!(LiveStroke::begin_with(&s, "paint.mixerBrush", &json!({"points": [[1, 1]]})).is_err());
|
||||
assert!(LiveStroke::begin_with(&s, "paint.pencil", &json!({"points": "none"})).is_err());
|
||||
}
|
||||
|
||||
/// Pencil latency on a 24 MP document: the time to start a live stroke, per pointer move, and
|
||||
/// to commit. `cargo test -p photocraft-engine --release --lib pencil_latency -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pencil_latency_24mp() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
|
||||
for size in [1.0f64, 9.0, 40.0] {
|
||||
let p = json!({"points": [[100.0, 100.0]], "size": size, "smoothing": 0.0});
|
||||
let t = std::time::Instant::now();
|
||||
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
|
||||
let begin = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut steps = Vec::new();
|
||||
let mut all = vec![[100.0, 100.0]];
|
||||
for i in 1..=300 {
|
||||
let q = [100.0 + i as f64 * 15.0, 100.0 + (i as f64 * 0.07).sin() * 900.0 + 1000.0];
|
||||
all.push(q);
|
||||
let t = std::time::Instant::now();
|
||||
live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap();
|
||||
steps.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let mut commit = p.clone();
|
||||
commit["points"] = json!(all);
|
||||
commit["seed"] = json!(live.seed);
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("paint.pencil", commit).unwrap();
|
||||
let done = t.elapsed().as_secs_f64() * 1000.0;
|
||||
steps.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"pencil {size:>4} px: begin {begin:.2} ms, per move p50 {:.3} ms p95 {:.3} ms max {:.3} ms, commit {done:.1} ms",
|
||||
steps[steps.len() / 2],
|
||||
steps[steps.len() * 95 / 100],
|
||||
steps[steps.len() - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() {
|
||||
// #189: the preview while dragging must be the committed stroke (within 1/255), with a soft
|
||||
|
||||
@@ -272,26 +272,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Fill…",
|
||||
["Edit"],
|
||||
Some("Shift+F5"),
|
||||
r##"{"contents":"color|pattern"="color","color":"#rrggbb|[r,g,b,a]"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":%=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##,
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##,
|
||||
has_pixel_or_channel,
|
||||
|s, p| {
|
||||
if p.get("contents").and_then(Value::as_str) == Some("pattern") {
|
||||
return crate::pattern_cmds::fill_with_pattern(s, p);
|
||||
}
|
||||
let color = color_param(p, "color", s.tools.foreground);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) };
|
||||
// Fill › Preserve Transparency (⇧⌥⌫ / ⇧⌘⌫): only where the layer already has pixels.
|
||||
let preserve = p.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false);
|
||||
s.edit("Fill", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let lock = lock || (preserve && !crate::channel_cmds::is_channel_target(p));
|
||||
pixels::fill_surface(surf, area, color, sel.as_ref(), lock);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
crate::fill_cmds::fill
|
||||
),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{}", has_pixel_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Edit › Fill… (`edit.fill`): Photoshop's Fill dialog as one command.
|
||||
//!
|
||||
//! Contents: Foreground / Background / Color / Content-Aware / Pattern / History / Black /
|
||||
//! 50% Gray / White, blended with `mode` at `opacity`, optionally preserving the target's
|
||||
//! transparency. The selection (or the whole canvas) is filled, as one history step ("Fill").
|
||||
//! Every parameter is validated: a bad one is an error, never a panic.
|
||||
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{blend_from_str, color_param, layer_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "edit.fill";
|
||||
|
||||
/// Every `contents` value, in the dialog's order.
|
||||
pub const CONTENTS: [&str; 9] = ["foreground", "background", "color", "contentAware", "pattern", "history", "black", "gray", "white"];
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// What a pixel of the filled area gets before blending.
|
||||
enum Source {
|
||||
Color([f32; 4]),
|
||||
/// Straight RGBA per pixel of `rect` (row-major).
|
||||
Pixels(Rect, Vec<[f32; 4]>),
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn at(&self, x: i32, y: i32) -> [f32; 4] {
|
||||
match self {
|
||||
Source::Color(c) => *c,
|
||||
Source::Pixels(r, px) => {
|
||||
if !r.contains(x, y) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let i = (y - r.y0) as usize * r.width() as usize + (x - r.x0) as usize;
|
||||
px.get(i).copied().unwrap_or([0.0; 4])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `contents` param; without one, `color` (the old `{"color": …}` form, foreground by default).
|
||||
fn contents(p: &Value) -> Result<&str> {
|
||||
match p.get("contents") {
|
||||
None | Some(Value::Null) => Ok("color"),
|
||||
Some(Value::String(c)) => {
|
||||
CONTENTS.iter().find(|k| k.eq_ignore_ascii_case(c)).copied().ok_or_else(|| bad(format!("unknown contents `{c}` ({})", CONTENTS.join("|"))))
|
||||
}
|
||||
Some(v) => Err(bad(format!("`contents` must be a string, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode(p: &Value) -> Result<BlendMode> {
|
||||
match p.get("mode") {
|
||||
None | Some(Value::Null) => Ok(BlendMode::Normal),
|
||||
Some(Value::String(m)) => blend_from_str(m).filter(|m| *m != BlendMode::PassThrough).ok_or_else(|| bad(format!("unknown blend mode `{m}`"))),
|
||||
Some(v) => Err(bad(format!("`mode` must be a blend mode name, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity in percent (0–100) → 0..1.
|
||||
fn opacity(p: &Value) -> Result<f32> {
|
||||
match p.get("opacity") {
|
||||
None | Some(Value::Null) => Ok(1.0),
|
||||
Some(v) => {
|
||||
let o = v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad(format!("`opacity` must be a number 0..100, not {v}")))?;
|
||||
Ok((o.clamp(0.0, 100.0) / 100.0) as f32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flag(p: &Value, k: &str, d: bool) -> Result<bool> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(d),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(v) => Err(bad(format!("`{k}` must be true or false, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Fill.
|
||||
pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let contents = contents(p)?;
|
||||
let mode = mode(p)?;
|
||||
let opacity = opacity(p)?;
|
||||
let preserve = flag(p, "preserveTransparency", false)?;
|
||||
let channel = crate::channel_cmds::is_channel_target(p);
|
||||
let id = if channel { None } else { Some(layer_param(s, p)?) };
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc_bounds = st.doc.bounds();
|
||||
let sel = st.doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc_bounds);
|
||||
let mut out = json!({ "contents": contents });
|
||||
let restore = contents == "history";
|
||||
let source = match contents {
|
||||
"foreground" => Source::Color(s.tools.foreground),
|
||||
"background" => Source::Color(s.tools.background),
|
||||
"color" => Source::Color(color_param(p, "color", s.tools.foreground)),
|
||||
"black" => Source::Color([0.0, 0.0, 0.0, 1.0]),
|
||||
"gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
|
||||
"white" => Source::Color([1.0; 4]),
|
||||
"pattern" => {
|
||||
let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?;
|
||||
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?;
|
||||
// Photoshop's Fill uses the canvas origin.
|
||||
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
|
||||
out["pattern"] = json!(pat.id);
|
||||
Source::Pixels(area, photocraft_compose::pattern::render(&tile, &place, area))
|
||||
}
|
||||
"history" => {
|
||||
let explicit = match p.get("state") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(format!("`state` must be a history state index, not {v}")))? as usize),
|
||||
};
|
||||
let past = st.history.past_len();
|
||||
let source_doc = match explicit {
|
||||
Some(i) if i == past => st.doc.clone(),
|
||||
Some(i) => st.history.state(i).ok_or_else(|| bad(format!("no history state {i} (0..={past})")))?,
|
||||
// The History Brush's default source: the oldest state still held.
|
||||
None => st.history.state(0).unwrap_or_else(|| st.doc.clone()),
|
||||
};
|
||||
let mut source_doc = (*source_doc).clone();
|
||||
let (src, _) = crate::channel_cmds::target_surface(&mut source_doc, id, p)
|
||||
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?;
|
||||
Source::Pixels(area, read_rgba(src, area))
|
||||
}
|
||||
"contentAware" => {
|
||||
let sel = sel.as_ref().ok_or_else(|| EngineError::Other("Content-Aware fill needs a selection".into()))?;
|
||||
if channel || crate::commands::is_mask_target(p) {
|
||||
return Err(EngineError::Other("Content-Aware fill works on layer pixels only".into()));
|
||||
}
|
||||
let adapt = flag(p, "colorAdaptation", true)?;
|
||||
let lid = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let surf = st.doc.layer(lid).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(1);
|
||||
content_aware(surf, sel, doc_bounds, adapt, seed)?
|
||||
}
|
||||
_ => return Err(bad(format!("unknown contents `{contents}`"))),
|
||||
};
|
||||
let area = area.intersect(&doc_bounds);
|
||||
if area.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
s.edit("Fill", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_rgba(src: &Surface, area: Rect) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
|
||||
src.read_rgba_into(area, &mut px);
|
||||
px
|
||||
}
|
||||
|
||||
/// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight
|
||||
/// RGBA over the sampling window. Only the selected pixels are used.
|
||||
fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result<Source> {
|
||||
use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level};
|
||||
let hole_bounds = sel.content_bounds().intersect(&canvas);
|
||||
if hole_bounds.is_empty() {
|
||||
return Err(EngineError::Other("the selection is outside the canvas".into()));
|
||||
}
|
||||
let ext = hole_bounds.width().max(hole_bounds.height());
|
||||
let window = hole_bounds.inflate(i32::try_from(ext * 3 / 4).unwrap_or(i32::MAX).max(32)).intersect(&canvas);
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
let (w, h) = (window.width() as usize, window.height() as usize);
|
||||
// Same cap as other whole-image operations: refuse absurd windows instead of allocating.
|
||||
if w.saturating_mul(h) > 400_000_000 {
|
||||
return Err(EngineError::Other("the selection is too large for Content-Aware fill".into()));
|
||||
}
|
||||
let img = surf.read_region(window);
|
||||
let mut hole = vec![false; w * h];
|
||||
for (i, hv) in hole.iter_mut().enumerate() {
|
||||
*hv = sel.sample_channel(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32, 0) > 0.0;
|
||||
}
|
||||
if hole.iter().all(|h| *h) {
|
||||
return Err(EngineError::Other("Content-Aware fill needs pixels outside the selection to sample from".into()));
|
||||
}
|
||||
let source = vec![true; w * h];
|
||||
let opts = FillOptions {
|
||||
color_adaptation: color_level(if color_adaptation { "default" } else { "none" }),
|
||||
rotations: rotation_level("none"),
|
||||
scale: false,
|
||||
mirror: false,
|
||||
seed,
|
||||
};
|
||||
if img.len() != w * h * n {
|
||||
return Err(EngineError::Other("could not read the layer pixels".into()));
|
||||
}
|
||||
let filled = fill(w, h, n, &img, &hole, &source, &opts);
|
||||
let px = filled.chunks_exact(n.max(1)).map(|c| to_rgba(&fmt, c)).collect();
|
||||
Ok(Source::Pixels(window, px))
|
||||
}
|
||||
|
||||
/// How the source goes onto the target.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Blend {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
/// Leave every pixel's alpha as it was (Preserve Transparency, or locked transparency).
|
||||
keep_alpha: bool,
|
||||
/// Normal mode replaces the pixels, transparency included (History: back to that state).
|
||||
restore: bool,
|
||||
}
|
||||
|
||||
/// `d` moved towards `c` by `k`, in premultiplied space (straight RGBA in and out).
|
||||
fn lerp(d: [f32; 4], c: [f32; 4], k: f32) -> [f32; 4] {
|
||||
let a = d[3] * (1.0 - k) + c[3] * k;
|
||||
if a <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let ch = |i: usize| (d[i] * d[3] * (1.0 - k) + c[i] * c[3] * k) / a;
|
||||
[ch(0), ch(1), ch(2), a]
|
||||
}
|
||||
|
||||
/// Blend `source` into `surf` over `area`: per pixel, the selection coverage times the opacity,
|
||||
/// in the blend mode.
|
||||
/// Works tile by tile in parallel on the encoded pixels (no full-surface f32 round trip).
|
||||
fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surface>, b: Blend) {
|
||||
use rayon::prelude::*;
|
||||
let fmt = surf.format();
|
||||
// An opaque colour in Normal mode at 100 % that may replace alpha: fully covered tiles become
|
||||
// one shared solid tile (copy-on-write), which costs nothing per pixel.
|
||||
let solid = match source {
|
||||
Source::Color(c) if c[3] >= 1.0 && b.mode == BlendMode::Normal && b.opacity >= 1.0 && (!b.keep_alpha || !fmt.alpha) => {
|
||||
let mut enc = [0.0f32; 8];
|
||||
let n = from_rgba_into(&fmt, *c, &mut enc);
|
||||
Some(surf.solid_tile(enc.get(..n).unwrap_or(&[])))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let tiles = surf.take_tiles(area);
|
||||
let done: Vec<_> = tiles
|
||||
.into_par_iter()
|
||||
.map(|(tc, tile)| {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() });
|
||||
if let Some(s) = &solid
|
||||
&& tr == tc.rect()
|
||||
&& mask.as_ref().is_none_or(|m| m.full(tr))
|
||||
{
|
||||
return (tc, s.clone());
|
||||
}
|
||||
let mut tile = std::sync::Arc::unwrap_or_clone(tile);
|
||||
blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b);
|
||||
(tc, std::sync::Arc::new(tile))
|
||||
})
|
||||
.collect();
|
||||
surf.put_tiles(done);
|
||||
}
|
||||
|
||||
/// The selection's coverage over one tile (its channel 0).
|
||||
struct Mask<'a> {
|
||||
bytes: &'a [u8],
|
||||
fmt: photocraft_color::PixelFormat,
|
||||
origin: Rect,
|
||||
}
|
||||
|
||||
impl Mask<'_> {
|
||||
fn at(&self, x: i32, y: i32) -> f32 {
|
||||
let bpp = self.fmt.bytes_per_pixel();
|
||||
// A default-pixel mask is one pixel long: every position reads it.
|
||||
let i = if self.bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp };
|
||||
self.bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0))
|
||||
}
|
||||
/// Fully selected over `r`?
|
||||
fn full(&self, r: Rect) -> bool {
|
||||
(r.y0..r.y1).all(|y| (r.x0..r.x1).all(|x| self.at(x, y) >= 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend into one tile's encoded pixels (`bytes`, the tile at `origin`) over `tr`.
|
||||
fn blend_tile(bytes: &mut [u8], fmt: &photocraft_color::PixelFormat, origin: Rect, tr: Rect, source: &Source, mask: Option<&Mask>, b: Blend) {
|
||||
let Blend { mode, opacity, keep_alpha, restore } = b;
|
||||
let n = fmt.channels();
|
||||
let bpp = fmt.bytes_per_pixel();
|
||||
let blend = if mode == BlendMode::Dissolve { BlendMode::Normal } else { mode };
|
||||
let mut px = [0.0f32; 8];
|
||||
let mut enc = [0.0f32; 8];
|
||||
// An opaque colour at full coverage in Normal mode just replaces the colour (and the alpha
|
||||
// unless it is kept): copy its encoded bytes.
|
||||
let opaque = match source {
|
||||
Source::Color(c) if c[3] >= 1.0 && mode == BlendMode::Normal => {
|
||||
let m = from_rgba_into(fmt, *c, &mut enc);
|
||||
let mut bytes = vec![0u8; bpp];
|
||||
photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), &mut bytes);
|
||||
// Keeping alpha: copy the colour samples only (alpha is the last sample).
|
||||
let keep = if (keep_alpha || !fmt.alpha) && fmt.alpha { bpp - bpp / n.max(1) } else { bpp };
|
||||
Some((bytes, keep))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
for y in tr.y0..tr.y1 {
|
||||
let row = (y - origin.y0) as usize * TILE_SIZE as usize;
|
||||
for x in tr.x0..tr.x1 {
|
||||
let mut k = mask.map_or(1.0, |m| m.at(x, y)) * opacity;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let i = (row + (x - origin.x0) as usize) * bpp;
|
||||
let Some(pb) = bytes.get_mut(i..i + bpp) else { continue };
|
||||
if k >= 1.0
|
||||
&& let Some((enc_px, keep)) = &opaque
|
||||
{
|
||||
if let (Some(dst), Some(src)) = (pb.get_mut(..*keep), enc_px.get(..*keep)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate().take(n) {
|
||||
*v = photocraft_color::read_sample(pb, fmt.sample, c);
|
||||
}
|
||||
let d = to_rgba(fmt, &px[..n.min(8)]);
|
||||
let mut c = source.at(x, y);
|
||||
if mode == BlendMode::Dissolve {
|
||||
// Dissolve: each pixel is either fully painted or untouched, with the coverage as odds.
|
||||
k = if photocraft_color::dither_noise(x, y) < c[3] * k { 1.0 } else { 0.0 };
|
||||
c[3] = 1.0;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut o = if restore && blend == BlendMode::Normal { lerp(d, c, k) } else { photocraft_color::blend::composite(blend, d, c, k) };
|
||||
if keep_alpha || !fmt.alpha {
|
||||
o[3] = d[3];
|
||||
}
|
||||
let m = from_rgba_into(fmt, o, &mut enc);
|
||||
photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), pb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn px(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
let st = s.active().unwrap();
|
||||
let l = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||||
l.surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.5 / 255.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_constant_contents() {
|
||||
let mut s = session();
|
||||
s.execute("tools.setColors", json!({"foreground": [1.0, 0.0, 0.0, 1.0], "background": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
for (c, want) in [
|
||||
("foreground", [1.0, 0.0, 0.0, 1.0]),
|
||||
("background", [0.0, 0.0, 1.0, 1.0]),
|
||||
("black", [0.0, 0.0, 0.0, 1.0]),
|
||||
("gray", [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
|
||||
("white", [1.0; 4]),
|
||||
] {
|
||||
s.execute(CMD, json!({"contents": c})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), want), "{c}: {:?}", px(&s, 5, 5));
|
||||
}
|
||||
s.execute(CMD, json!({"contents": "color", "color": "#00ff00"})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [0.0, 1.0, 0.0, 1.0]));
|
||||
// The old form: a colour, or the foreground.
|
||||
s.execute(CMD, json!({"color": "#ffff00"})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [1.0, 1.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mode_and_preserve_transparency() {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
// Preserve Transparency: the empty right half stays empty.
|
||||
s.execute(CMD, json!({"contents": "black", "opacity": 50, "preserveTransparency": true})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&s, 5, 5));
|
||||
assert_eq!(px(&s, 30, 5)[3], 0.0);
|
||||
// Multiply with white leaves the grey.
|
||||
s.execute(CMD, json!({"contents": "white", "mode": "multiply", "preserveTransparency": true})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [0.5, 0.5, 0.5, 1.0]));
|
||||
// Without it, the transparent half is filled.
|
||||
s.execute(CMD, json!({"contents": "white", "opacity": 100})).unwrap();
|
||||
assert_eq!(px(&s, 30, 5), [1.0; 4]);
|
||||
// Dissolve paints whole pixels only.
|
||||
s.execute(CMD, json!({"contents": "black", "mode": "dissolve", "opacity": 50})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||||
let blacks = (0..40).flat_map(|x| (0..30).map(move |y| (x, y))).filter(|&(x, y)| surf.rgba(x, y)[0] < 0.01).count();
|
||||
assert!(blacks > 300 && blacks < 900, "{blacks}");
|
||||
assert!((0..40).all(|x| {
|
||||
let v = surf.rgba(x, 3)[0];
|
||||
v < 0.01 || v > 0.99
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_pattern_and_content_aware() {
|
||||
let mut s = session();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "black"})).unwrap();
|
||||
// History: back to state 1 (after the white fill).
|
||||
s.execute(CMD, json!({"contents": "history", "state": 1})).unwrap();
|
||||
assert_eq!(px(&s, 5, 5), [1.0; 4]);
|
||||
// The default source is the oldest state: the empty layer.
|
||||
s.execute(CMD, json!({"contents": "history"})).unwrap();
|
||||
assert_eq!(px(&s, 5, 5)[3], 0.0);
|
||||
assert!(s.execute(CMD, json!({"contents": "history", "state": 999})).is_err());
|
||||
let r = s.execute(CMD, json!({"contents": "pattern", "pattern": "Diagonal Lines"})).unwrap();
|
||||
assert!(r["pattern"].is_string());
|
||||
assert!(px(&s, 5, 5)[3] > 0.0);
|
||||
// Content-Aware fills the hole from what is around it.
|
||||
s.execute(CMD, json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 15, "y": 10, "width": 6, "height": 6})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "contentAware", "colorAdaptation": false})).unwrap();
|
||||
assert!(close(px(&s, 17, 12), [0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]), "{:?}", px(&s, 17, 12));
|
||||
}
|
||||
|
||||
/// `cargo test -p photocraft-engine --release --lib fill_cmds::tests::timing_24mp -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn timing_24mp() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
let time = |s: &mut Session, label: &str, p: Value| {
|
||||
let mut v = Vec::new();
|
||||
for i in 0..5 {
|
||||
let mut p = p.clone();
|
||||
if p.get("contents").is_none() {
|
||||
p["color"] = json!(if i % 2 == 0 { "#336699" } else { "#996633" });
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
s.execute(CMD, p).unwrap();
|
||||
v.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
v.sort_by(f64::total_cmp);
|
||||
println!("{label:<48} median {:>8.1} ms min {:>8.1} ms", v[2], v[0]);
|
||||
};
|
||||
time(&mut s, "colour, no selection", json!({}));
|
||||
time(&mut s, "colour, preserve transparency", json!({"preserveTransparency": true}));
|
||||
time(&mut s, "colour 50% multiply", json!({"opacity": 50, "mode": "multiply"}));
|
||||
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 5000, "height": 3000})).unwrap();
|
||||
time(&mut s, "colour, 15 MP rectangle selection", json!({}));
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
// The old per-pixel f32 path, for comparison.
|
||||
let st = s.active().unwrap();
|
||||
let mut surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
let t = std::time::Instant::now();
|
||||
crate::pixels::fill_surface(&mut surf, Rect::new(0, 0, 6000, 4000), [0.2, 0.4, 0.6, 1.0], None, false);
|
||||
println!("{:<48} {:>8.1} ms", "old fill_surface, no selection", t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors() {
|
||||
let mut s = session();
|
||||
for p in [
|
||||
json!({"contents": "plaid"}),
|
||||
json!({"contents": 3}),
|
||||
json!({"mode": "sideways"}),
|
||||
json!({"mode": "passThrough"}),
|
||||
json!({"opacity": "lots"}),
|
||||
json!({"preserveTransparency": "yes"}),
|
||||
json!({"contents": "contentAware"}),
|
||||
json!({"contents": "pattern", "pattern": "no such pattern"}),
|
||||
json!({"contents": "history", "state": -1}),
|
||||
json!({"contents": "contentAware", "colorAdaptation": 1}),
|
||||
] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// Everything selected leaves nothing to sample from.
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
assert!(s.execute(CMD, json!({"contents": "contentAware"})).is_err());
|
||||
// Out-of-range opacity is clamped.
|
||||
s.execute(CMD, json!({"contents": "white", "opacity": 1e300})).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub mod edit_menu_cmds;
|
||||
pub mod eraser_cmds;
|
||||
pub mod extra_cmds;
|
||||
pub mod file_cmds;
|
||||
pub mod fill_cmds;
|
||||
pub mod fill_key_cmds;
|
||||
pub mod filters;
|
||||
pub mod filters_ext;
|
||||
|
||||
@@ -15,7 +15,7 @@ use photocraft_doc::{Color, Document, Fill, Layer, LayerContent, Pattern, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, layer_param};
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The app-level pattern library (Window › Patterns).
|
||||
@@ -315,50 +315,6 @@ fn new_fill_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layer": id.0, "pattern": pat.id}))
|
||||
}
|
||||
|
||||
/// Edit › Fill with `"contents": "pattern"`: tiles the pattern over the selection (or the whole
|
||||
/// target), composited at `opacity` (Normal), respecting transparency locks.
|
||||
pub fn fill_with_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pat = resolve_param(s, "edit.fill", p)?;
|
||||
let (scale, angle, _, phase) = placement(p);
|
||||
let opacity = (p.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) };
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("edit.fill", "the pattern is empty"))?;
|
||||
s.edit("Fill", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
|
||||
// Photoshop's Fill uses the canvas origin.
|
||||
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
let mut region = surf.read_region(area);
|
||||
let src = photocraft_compose::pattern::render(&tile, &place, area);
|
||||
let w = area.width() as usize;
|
||||
for (i, (px, c)) in region.chunks_exact_mut(n).zip(&src).enumerate() {
|
||||
let k = sel.as_ref().map_or(1.0, |m| m.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0)) * opacity;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let d = photocraft_raster::to_rgba(&fmt, px);
|
||||
let sa = c[3] * k;
|
||||
let oa = sa + d[3] * (1.0 - sa);
|
||||
let mut o = [0.0f32; 4];
|
||||
if oa > 0.0 {
|
||||
for ch in 0..3 {
|
||||
o[ch] = (c[ch] * sa + d[ch] * d[3] * (1.0 - sa)) / oa;
|
||||
}
|
||||
}
|
||||
o[3] = if lock || !fmt.alpha { d[3] } else { oa };
|
||||
let mut enc = [0.0f32; 8];
|
||||
photocraft_raster::from_rgba_into(&fmt, o, &mut enc);
|
||||
px.copy_from_slice(&enc[..n]);
|
||||
}
|
||||
surf.write_region(area, ®ion);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"pattern": pat.id}))
|
||||
}
|
||||
|
||||
fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pat = resolve_param(s, "brush.texturePattern", p)?;
|
||||
// Brush textures are grey: luminance of the composite over white.
|
||||
|
||||
@@ -744,6 +744,9 @@ pub struct Preferences {
|
||||
/// as the user changes it and restored at launch when Workspace › Remember Workspace
|
||||
/// Changes is on. JSON owned by the shell.
|
||||
pub panel_layout: Value,
|
||||
/// The last choices of dialogs that remember them across restarts, by command id (Edit ›
|
||||
/// Fill…: `"edit.fill"` → its params). JSON owned by the shell.
|
||||
pub dialogs: BTreeMap<String, Value>,
|
||||
/// File › Scripts › Script Events Manager: event → script bindings.
|
||||
pub script_events: crate::automate_cmds::ScriptEvents,
|
||||
}
|
||||
|
||||
@@ -128,6 +128,44 @@ fn pencil_is_aliased() {
|
||||
assert!((10..32).any(|y| (0..64).any(|x| (0.01..0.99).contains(&s.rgba(x, y)[3]))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pencil_dabs_sit_on_the_pixel_grid() {
|
||||
use crate::render::{grid_center, grid_square};
|
||||
// Odd sizes centre on the pixel holding the point, even ones on the nearest corner.
|
||||
assert_eq!(grid_center(10.0, 5.0, 1.0), (10.5, 5.5));
|
||||
assert_eq!(grid_center(10.9, 5.2, 3.0), (10.5, 5.5));
|
||||
assert_eq!(grid_center(10.4, 5.6, 2.0), (10.0, 6.0));
|
||||
assert_eq!(grid_square(10.2, 5.7, 1.0), [10.0, 5.0, 11.0, 6.0]);
|
||||
assert_eq!(grid_square(10.2, 5.7, 4.0), [8.0, 4.0, 12.0, 8.0]);
|
||||
assert_eq!(grid_square(f64::NAN, 0.0, f32::NAN).len(), 4);
|
||||
// A 1 px pencil paints exactly the pixel under each point, even on a pixel corner, with
|
||||
// no partial alpha.
|
||||
let b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() };
|
||||
let s = paint(&b, &[StrokePoint::new(7.0, 4.0, 1.0)], 16, 16);
|
||||
for y in 0..16 {
|
||||
for x in 0..16 {
|
||||
assert_eq!(s.rgba(x, y)[3], if (x, y) == (7, 4) { 1.0 } else { 0.0 }, "({x},{y})");
|
||||
}
|
||||
}
|
||||
// A horizontal 1 px line is one pixel thick and unbroken.
|
||||
let s = paint(&b, &line(2.0, 20.0, 9.3), 24, 16);
|
||||
for x in 2..20 {
|
||||
assert_eq!(s.rgba(x, 9)[3], 1.0, "x {x}");
|
||||
assert_eq!(s.rgba(x, 8)[3] + s.rgba(x, 10)[3], 0.0, "x {x}");
|
||||
}
|
||||
// A 4 px dab stays inside its cursor square.
|
||||
let b = BrushSettings { size: 4.0, ..b };
|
||||
let s = paint(&b, &[StrokePoint::new(10.2, 5.7, 1.0)], 24, 16);
|
||||
let [x0, y0, x1, y1] = grid_square(10.2, 5.7, 4.0);
|
||||
for y in 0..16 {
|
||||
for x in 0..24 {
|
||||
let inside = (x as f64) >= x0 && (x as f64) < x1 && (y as f64) >= y0 && (y as f64) < y1;
|
||||
assert!(inside || s.rgba(x, y)[3] == 0.0, "({x},{y}) outside the square is painted");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.rgba(9, 5)[3], 1.0);
|
||||
}
|
||||
|
||||
// ---------- spacing ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,7 +35,7 @@ pub use brush::{
|
||||
Smoothing, Texture, TipShape, Transfer,
|
||||
};
|
||||
pub use mixer::MixerSettings;
|
||||
pub use render::{BrushContext, StrokeRenderer, render_stroke};
|
||||
pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke};
|
||||
pub use tile::GrayTile;
|
||||
|
||||
/// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation.
|
||||
|
||||
@@ -26,6 +26,25 @@ pub const COV_TILE: i32 = 64;
|
||||
const NOISE_SALT: u64 = 0x006E_6F69_7365;
|
||||
|
||||
#[inline]
|
||||
/// Where an aliased (Pencil) dab of `diameter` pixels centred near (`x`, `y`) lands on the pixel
|
||||
/// grid: the centre of the pixel holding the point for odd diameters, the nearest pixel corner
|
||||
/// for even ones. Its footprint is then a whole-pixel block around that point, the same square
|
||||
/// the Pencil cursor shows ([`grid_square`]).
|
||||
pub fn grid_center(x: f64, y: f64, diameter: f32) -> (f32, f32) {
|
||||
let n = if diameter.is_finite() { diameter.round().max(1.0) as i64 } else { 1 };
|
||||
let snap = |v: f64| if n % 2 == 1 { v.floor() + 0.5 } else { v.round() };
|
||||
(snap(x) as f32, snap(y) as f32)
|
||||
}
|
||||
|
||||
/// The whole-pixel square `[x0, y0, x1, y1]` an aliased dab of `diameter` pixels at (`x`, `y`)
|
||||
/// can touch (see [`grid_center`]): the Pencil's cursor.
|
||||
pub fn grid_square(x: f64, y: f64, diameter: f32) -> [f64; 4] {
|
||||
let n = if diameter.is_finite() { f64::from(diameter.round().clamp(1.0, 100_000.0)) } else { 1.0 };
|
||||
let (cx, cy) = grid_center(x, y, n as f32);
|
||||
let h = n / 2.0;
|
||||
[f64::from(cx) - h, f64::from(cy) - h, f64::from(cx) + h, f64::from(cy) + h]
|
||||
}
|
||||
|
||||
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
@@ -141,7 +160,7 @@ impl BrushContext {
|
||||
let wet = b.wet_edges && !dual;
|
||||
let noise = b.noise && !dual;
|
||||
let tex_tip = !dual && b.texture.enabled && b.texture.each_tip && self.texture.is_some();
|
||||
let (cx, cy) = (d.center.x as f32, d.center.y as f32);
|
||||
let (cx, cy) = if aliased { grid_center(d.center.x, d.center.y, 2.0 * d.radius) } else { (d.center.x as f32, d.center.y as f32) };
|
||||
let (sn, cs) = d.angle.sin_cos();
|
||||
let (fx, fy) = (if d.flip_x { -1.0 } else { 1.0 }, if d.flip_y { -1.0 } else { 1.0 });
|
||||
// Brush Projection: stretch the sampling coordinates along the tilt direction, which
|
||||
|
||||
@@ -395,6 +395,40 @@ impl Surface {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the tiles covering `r` out of the surface (missing ones filled with the default
|
||||
/// pixel), so they can be edited elsewhere, e.g. in parallel: `Arc::unwrap_or_clone` copies a
|
||||
/// tile only if an undo snapshot still shares it. Give them back with [`Surface::put_tiles`].
|
||||
pub fn take_tiles(&mut self, r: Rect) -> Vec<(TileCoord, Arc<Tile>)> {
|
||||
let fmt = self.format;
|
||||
let mut blank: Option<Arc<Tile>> = None;
|
||||
r.tiles()
|
||||
.map(|c| {
|
||||
let t = self.tiles.remove(&c).unwrap_or_else(|| blank.get_or_insert_with(|| Arc::new(Tile::filled(&fmt, &self.default_pixel))).clone());
|
||||
(c, t)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Put tiles back (see [`Surface::take_tiles`]); each replaces the tile at its coordinate.
|
||||
pub fn put_tiles(&mut self, tiles: impl IntoIterator<Item = (TileCoord, Arc<Tile>)>) {
|
||||
for (c, t) in tiles {
|
||||
self.tiles.insert(c, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tile of this surface's format filled with `px` (normalised channel values). Sharing one
|
||||
/// across many coordinates fills them without copying (tiles are copy-on-write).
|
||||
pub fn solid_tile(&self, px: &[f32]) -> Arc<Tile> {
|
||||
let mut enc = vec![0u8; self.format.bytes_per_pixel()];
|
||||
encode_pixel(&self.format, px, &mut enc);
|
||||
Arc::new(Tile::filled(&self.format, &enc))
|
||||
}
|
||||
|
||||
/// The default pixel's encoded bytes.
|
||||
pub fn default_bytes(&self) -> &[u8] {
|
||||
&self.default_pixel
|
||||
}
|
||||
|
||||
/// Fill a rectangle with one pixel value.
|
||||
pub fn fill_rect(&mut self, r: Rect, px: &[f32]) {
|
||||
let n = self.channels();
|
||||
|
||||
@@ -178,15 +178,41 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> {
|
||||
|
||||
/// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). The
|
||||
/// stroke smoothing is the session brush's (the options bar's Smoothing %).
|
||||
fn stroke_params(app: &PhotocraftApp, erase: bool, points: &[Vec<f64>]) -> serde_json::Value {
|
||||
json!({ "points": points, "erase": erase, "zoom": app.current_zoom(), "target": paint_target(app) })
|
||||
fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec<f64>]) -> serde_json::Value {
|
||||
let mut p = json!({ "points": points, "erase": erase, "zoom": app.current_zoom(), "target": paint_target(app) });
|
||||
if tool == Tool::Pencil {
|
||||
p["autoErase"] = json!(app.ui.tool_options.pencil_auto_erase);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Tools whose strokes the engine renders while they are drawn (`LiveStroke`).
|
||||
pub(crate) fn strokes_live(tool: Tool) -> bool {
|
||||
matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser)
|
||||
}
|
||||
|
||||
/// The command a live-stroking tool commits: the Pencil's `paint.pencil`, else `paint.stroke`.
|
||||
pub(crate) fn stroke_command(tool: Tool) -> &'static str {
|
||||
if tool == Tool::Pencil { "paint.pencil" } else { "paint.stroke" }
|
||||
}
|
||||
|
||||
/// The Pencil's cursor at `doc` (document pixels): the whole-pixel square its dab fills
|
||||
/// (`paint::grid_square`), in screen points with its edges on physical pixels (`ppp` = pixels
|
||||
/// per point), so it lines up with the pixel grid at any zoom.
|
||||
pub(crate) fn pencil_cursor_rect(xf: &ViewXform, doc: [f64; 2], size: f32, ppp: f32) -> Rect {
|
||||
let [x0, y0, x1, y1] = photocraft_engine::paint::grid_square(doc[0], doc[1], size);
|
||||
let r = Rect::from_two_pos(xf.to_screen(x0 as f32, y0 as f32), xf.to_screen(x1 as f32, y1 as f32));
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let snap = |v: f32| (v * ppp).round() / ppp;
|
||||
Rect::from_min_max(pos2(snap(r.min.x), snap(r.min.y)), pos2(snap(r.max.x), snap(r.max.y)))
|
||||
}
|
||||
|
||||
fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
|
||||
static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let st = app.session.active()?;
|
||||
let d = app.drag.as_ref()?;
|
||||
let stroke = photocraft_engine::brush_cmds::LiveStroke::begin(&app.session, &stroke_params(app, d.erase, &app.stylus.stroke_points(&d.points))).ok()?;
|
||||
let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
|
||||
let stroke = photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(d.tool), &p).ok()?;
|
||||
let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff;
|
||||
let damage = vec![stroke.bounds()];
|
||||
Some(LiveStroke { stroke, doc: st.doc.id, revision: st.revision, key: (1 << 44) | (n << 20), damage, fed: d.points.len() })
|
||||
@@ -1498,6 +1524,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crosshair(5.0);
|
||||
egui::CursorIcon::None
|
||||
}
|
||||
// The Pencil: the square of whole pixels its dab fills, on the pixel grid.
|
||||
_ if tool == Tool::Pencil => {
|
||||
let ppp = painter.ctx().pixels_per_point();
|
||||
let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp);
|
||||
let px = 1.0 / ppp;
|
||||
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside);
|
||||
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside);
|
||||
// Too small to see where it is: the hotspot as well.
|
||||
if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 {
|
||||
crosshair(4.0);
|
||||
}
|
||||
egui::CursorIcon::None
|
||||
}
|
||||
_ => {
|
||||
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
|
||||
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
|
||||
@@ -1738,7 +1777,7 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
|
||||
}
|
||||
match d.tool {
|
||||
// The canvas shows the live stroke itself (`LiveStroke`).
|
||||
Tool::Brush | Tool::Eraser => {}
|
||||
Tool::Brush | Tool::Pencil | Tool::Eraser => {}
|
||||
t if t.is_brushlike() || t == Tool::QuickSelection => {
|
||||
// Retouching strokes preview as a translucent trail of the brush footprint: a mask,
|
||||
// not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189).
|
||||
@@ -1877,6 +1916,12 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, pressure } => {
|
||||
// Painting a type, shape, Smart Object or fill layer asks to rasterize it first
|
||||
// (⌥-click with the Clone Stamp or Healing Brush only sets the source).
|
||||
let sets_source = matches!(tool, Tool::CloneStamp | Tool::Healing) && mods.alt;
|
||||
if !sets_source && crate::rasterize_prompt::intercept(app, tool, x, y, pressure) {
|
||||
return;
|
||||
}
|
||||
match tool {
|
||||
Tool::Pen => {
|
||||
crate::vector_ui::pen_down(app, x, y);
|
||||
@@ -1932,7 +1977,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
if from.is_some() {
|
||||
app.stylus.record_point();
|
||||
}
|
||||
app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app) } else { None };
|
||||
app.live_stroke = if strokes_live(tool) { begin_live_stroke(app) } else { None };
|
||||
}
|
||||
ToolEvent::Move { x, y, pressure } => {
|
||||
if tool == Tool::Type && app.drag.is_none() {
|
||||
@@ -2007,15 +2052,15 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.modifiers),
|
||||
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 => {
|
||||
Tool::Brush | Tool::Pencil | Tool::Eraser => {
|
||||
let live = app.live_stroke.take();
|
||||
let mut p = stroke_params(app, d.erase, &app.stylus.stroke_points(&d.points));
|
||||
let mut p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
|
||||
if let Some(l) = &live {
|
||||
p["seed"] = json!(l.stroke.seed);
|
||||
}
|
||||
// The canvas already shows the stroke: let the commit's damage rect refresh it rather
|
||||
// than recompositing the whole document.
|
||||
if app.run("paint.stroke", p).is_ok()
|
||||
if app.run(stroke_command(d.tool), p).is_ok()
|
||||
&& let Some(l) = live
|
||||
{
|
||||
let display_key = app.session.active().map_or(0, |st| canvas_display(app, &st.doc).1);
|
||||
|
||||
@@ -100,6 +100,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(10.0);
|
||||
ui.weak("egui · wgpu · photocraft-engine");
|
||||
}
|
||||
DialogKind::Command if crate::fill_ui::owns(&fields) => crate::fill_ui::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::rasterize_prompt::owns(&fields) => crate::rasterize_prompt::body(ui, &fields),
|
||||
DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::color_picker_ui::owns(&fields) => crate::color_picker_ui::body(ui, &mut fields),
|
||||
@@ -212,6 +214,8 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
||||
}
|
||||
r
|
||||
}
|
||||
DialogKind::Command if crate::fill_ui::owns(&d.fields) => crate::fill_ui::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::variables_ui::owns(&d.fields) => crate::variables_ui::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::color_picker_ui::owns(&d.fields) => crate::color_picker_ui::confirm(app, &d.fields),
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Edit › Fill… (Shift+F5, Shift+Backspace): Photoshop's Fill dialog in front of `edit.fill`.
|
||||
//!
|
||||
//! Contents (Foreground, Background, Color…, Content-Aware, Pattern, History, Black, 50% Gray,
|
||||
//! White) with the content's own options, then Blending: Mode, Opacity and Preserve
|
||||
//! Transparency. The fields are the command's params, so `ui.dialog.set` / `ui.dialog.confirm`
|
||||
//! drive it like any dialog. OK remembers the choices in the preferences (`dialogs["edit.fill"]`),
|
||||
//! which are saved to disk, so the dialog opens with them again after a restart.
|
||||
|
||||
use egui::{Align2, Sense, pos2, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
pub const COMMAND: &str = "edit.fill";
|
||||
const MARK: &str = "__fill";
|
||||
|
||||
/// The Contents menu: (param, label), in Photoshop's order.
|
||||
pub const CONTENTS: [(&str, &str); 9] = [
|
||||
("foreground", "Foreground Color"),
|
||||
("background", "Background Color"),
|
||||
("color", "Color…"),
|
||||
("contentAware", "Content-Aware"),
|
||||
("pattern", "Pattern"),
|
||||
("history", "History"),
|
||||
("black", "Black"),
|
||||
("gray", "50% Gray"),
|
||||
("white", "White"),
|
||||
];
|
||||
|
||||
/// Params the dialog edits and remembers.
|
||||
const KEYS: [&str; 7] = ["contents", "color", "pattern", "colorAdaptation", "mode", "opacity", "preserveTransparency"];
|
||||
|
||||
/// Is this dialog the Fill dialog?
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.contains_key(MARK)
|
||||
}
|
||||
|
||||
fn hex(c: [f32; 4]) -> String {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
|
||||
}
|
||||
|
||||
/// A remembered value, if it is one the dialog can show (a corrupt preference is ignored).
|
||||
fn valid(app: &PhotocraftApp, key: &str, v: &Value) -> bool {
|
||||
match key {
|
||||
"contents" => v.as_str().is_some_and(|c| CONTENTS.iter().any(|(k, _)| *k == c)),
|
||||
"mode" => v.as_str().and_then(photocraft_engine::commands::blend_from_str).is_some_and(|m| m != photocraft_color::BlendMode::PassThrough),
|
||||
"opacity" => v.as_f64().is_some_and(|o| (0.0..=100.0).contains(&o)),
|
||||
"color" => v.as_str().is_some_and(|h| h.len() == 7 && h.starts_with('#') && h.get(1..).is_some_and(|d| d.chars().all(|c| c.is_ascii_hexdigit()))),
|
||||
"pattern" => v.as_str().is_some_and(|p| app.session.patterns.items.iter().any(|q| q.id == p)),
|
||||
"colorAdaptation" | "preserveTransparency" => v.is_boolean(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
|
||||
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
|
||||
let mut f = Map::new();
|
||||
f.insert(MARK.into(), json!(true));
|
||||
f.insert("__command".into(), json!(COMMAND));
|
||||
f.insert("__label".into(), json!("Fill"));
|
||||
f.insert("contents".into(), json!("foreground"));
|
||||
f.insert("color".into(), json!(hex(app.session.tools.foreground)));
|
||||
if let Some(p) = app.session.patterns.items.first() {
|
||||
f.insert("pattern".into(), json!(p.id));
|
||||
}
|
||||
f.insert("colorAdaptation".into(), json!(true));
|
||||
f.insert("mode".into(), json!("normal"));
|
||||
f.insert("opacity".into(), json!(100.0));
|
||||
f.insert("preserveTransparency".into(), json!(false));
|
||||
if let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) {
|
||||
for k in KEYS {
|
||||
if let Some(v) = saved.get(k).filter(|v| valid(app, k, v)) {
|
||||
f.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Open the Fill dialog; returns its id.
|
||||
pub fn open(app: &mut PhotocraftApp) -> u64 {
|
||||
let f = fields(app);
|
||||
app.ui.open_dialog(crate::state::DialogKind::Command, f)
|
||||
}
|
||||
|
||||
/// The `edit.fill` params of the dialog's fields: only the options of the chosen contents.
|
||||
pub fn params(f: &Map<String, Value>) -> Value {
|
||||
let contents = f.get("contents").and_then(Value::as_str).unwrap_or("foreground");
|
||||
let mut p = json!({ "contents": contents });
|
||||
for k in ["mode", "opacity", "preserveTransparency"] {
|
||||
if let Some(v) = f.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
let extra = match contents {
|
||||
"color" => Some("color"),
|
||||
"pattern" => Some("pattern"),
|
||||
"contentAware" => Some("colorAdaptation"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = extra
|
||||
&& let Some(v) = f.get(k)
|
||||
{
|
||||
p[k] = v.clone();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// OK: remember the choices, then fill.
|
||||
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
let remembered: Map<String, Value> = KEYS.iter().filter_map(|k| f.get(*k).map(|v| (k.to_string(), v.clone()))).collect();
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), Value::Object(remembered)));
|
||||
app.run(COMMAND, params(f))
|
||||
}
|
||||
|
||||
fn label(ui: &mut egui::Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(112.0, 22.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.right() - 6.0, r.center().y), Align2::RIGHT_CENTER, text, egui::FontId::proportional(12.0), t.text_dim);
|
||||
}
|
||||
|
||||
fn get_str(f: &Map<String, Value>, k: &str, d: &str) -> String {
|
||||
f.get(k).and_then(Value::as_str).unwrap_or(d).to_string()
|
||||
}
|
||||
|
||||
fn parse_hex(h: &str) -> [u8; 3] {
|
||||
let d = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0);
|
||||
[d(1), d(3), d(5)]
|
||||
}
|
||||
|
||||
/// The dialog body.
|
||||
pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
let mut contents = get_str(f, "contents", "foreground");
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Contents:");
|
||||
let opts: Vec<(String, &str)> = CONTENTS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
|
||||
if crate::widgets::dropdown(ui, "fill-contents", &mut contents, &opts, 170.0) {
|
||||
f.insert("contents".into(), json!(contents));
|
||||
}
|
||||
});
|
||||
match contents.as_str() {
|
||||
"color" => {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Color:");
|
||||
let mut rgb = parse_hex(&get_str(f, "color", "#000000"));
|
||||
if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() {
|
||||
f.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
}
|
||||
});
|
||||
}
|
||||
"pattern" => {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Custom Pattern:");
|
||||
let mut pat = get_str(f, "pattern", "");
|
||||
let opts: Vec<(String, &str)> = app.session.patterns.items.iter().map(|p| (p.id.clone(), p.display_name())).collect();
|
||||
if opts.is_empty() {
|
||||
ui.label(egui::RichText::new("No patterns").color(Tokens::get(ui.ctx()).text_faint));
|
||||
} else if crate::widgets::dropdown(ui, "fill-pattern", &mut pat, &opts, 170.0) {
|
||||
f.insert("pattern".into(), json!(pat));
|
||||
}
|
||||
});
|
||||
}
|
||||
"contentAware" => {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "");
|
||||
let mut on = f.get("colorAdaptation").and_then(Value::as_bool).unwrap_or(true);
|
||||
if crate::widgets::checkbox(ui, &mut on, "Color Adaptation").changed() {
|
||||
f.insert("colorAdaptation".into(), json!(on));
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
crate::widgets::section_label(ui, "Blending");
|
||||
crate::widgets::hairline(ui);
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Mode:");
|
||||
let mut mode = get_str(f, "mode", "normal");
|
||||
let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (mode_key(*m), m.label())).collect();
|
||||
if crate::widgets::dropdown(ui, "fill-mode", &mut mode, &opts, 170.0) {
|
||||
f.insert("mode".into(), json!(mode));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Opacity:");
|
||||
let mut o = f.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32;
|
||||
if crate::widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 70.0).changed() {
|
||||
f.insert("opacity".into(), json!(o.round()));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "");
|
||||
let mut on = f.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false);
|
||||
if crate::widgets::checkbox(ui, &mut on, "Preserve Transparency").changed() {
|
||||
f.insert("preserveTransparency".into(), json!(on));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The `mode` param naming a blend mode (`"normal"`, `"colorBurn"`…), which the engine parses.
|
||||
fn mode_key(m: photocraft_color::BlendMode) -> String {
|
||||
let s = format!("{m:?}");
|
||||
let mut c = s.chars();
|
||||
c.next().map(|f| f.to_ascii_lowercase().to_string() + c.as_str()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fill_ui_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,162 @@
|
||||
use egui::{Key, Modifiers, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn px(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] {
|
||||
let st = app.session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.5 / 255.0)
|
||||
}
|
||||
|
||||
/// Open the dialog, set `fields`, press OK.
|
||||
fn fill(app: &mut PhotocraftApp, fields: Value) -> Result<Value, String> {
|
||||
let id = open(app);
|
||||
let d = app.ui.dialog_mut(id).unwrap();
|
||||
for (k, v) in fields.as_object().unwrap() {
|
||||
d.fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
crate::dialogs::confirm(app, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_contents_option_fills() {
|
||||
let mut app = app();
|
||||
for (contents, want) in [
|
||||
("foreground", [1.0, 0.0, 0.0, 1.0]),
|
||||
("background", [0.0, 0.0, 1.0, 1.0]),
|
||||
("black", [0.0, 0.0, 0.0, 1.0]),
|
||||
("gray", [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
|
||||
("white", [1.0; 4]),
|
||||
] {
|
||||
fill(&mut app, json!({ "contents": contents })).unwrap();
|
||||
assert!(close(px(&app, 3, 3), want), "{contents}: {:?}", px(&app, 3, 3));
|
||||
}
|
||||
fill(&mut app, json!({"contents": "color", "color": "#00ff00"})).unwrap();
|
||||
assert!(close(px(&app, 3, 3), [0.0, 1.0, 0.0, 1.0]));
|
||||
// History: the default source is the opening state (the empty layer).
|
||||
fill(&mut app, json!({"contents": "history"})).unwrap();
|
||||
assert_eq!(px(&app, 3, 3)[3], 0.0);
|
||||
let pattern = app.session.patterns.items.first().map(|p| p.id.clone()).unwrap();
|
||||
fill(&mut app, json!({"contents": "pattern", "pattern": pattern})).unwrap();
|
||||
assert!(px(&app, 3, 3)[3] > 0.0);
|
||||
// Content-Aware fills a selected hole from around it.
|
||||
fill(&mut app, json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 15, "y": 10, "width": 6, "height": 6})).unwrap();
|
||||
fill(&mut app, json!({"contents": "white"})).unwrap();
|
||||
fill(&mut app, json!({"contents": "contentAware", "colorAdaptation": false})).unwrap();
|
||||
assert!(close(px(&app, 17, 12), [0.2, 0.4, 0.6, 1.0]), "{:?}", px(&app, 17, 12));
|
||||
// Each fill is one history step.
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_opacity_and_preserve_transparency() {
|
||||
let mut app = app();
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
|
||||
fill(&mut app, json!({"contents": "white"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
fill(&mut app, json!({"contents": "black", "opacity": 50, "preserveTransparency": true})).unwrap();
|
||||
assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&app, 5, 5));
|
||||
assert_eq!(px(&app, 30, 5)[3], 0.0, "transparent pixels stay transparent");
|
||||
fill(&mut app, json!({"contents": "white", "opacity": 100, "mode": "multiply", "preserveTransparency": true})).unwrap();
|
||||
assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choices_persist_across_restarts() {
|
||||
let mut app = app();
|
||||
// Photoshop's defaults the first time.
|
||||
let f = fields(&app);
|
||||
assert_eq!((f["contents"].as_str(), f["mode"].as_str(), f["opacity"].as_f64()), (Some("foreground"), Some("normal"), Some(100.0)));
|
||||
fill(&mut app, json!({"contents": "gray", "mode": "multiply", "opacity": 40, "preserveTransparency": true})).unwrap();
|
||||
// A fresh app with the saved preferences opens the dialog with the same choices.
|
||||
let saved = app.session.prefs_to_json();
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.load_prefs_json(&saved).unwrap();
|
||||
let mut again = PhotocraftApp::new(s, Default::default());
|
||||
again.run("file.new", json!({"width": 10, "height": 10})).unwrap();
|
||||
let f = fields(&again);
|
||||
assert_eq!(f["contents"], json!("gray"));
|
||||
assert_eq!(f["mode"], json!("multiply"));
|
||||
assert_eq!(f["opacity"].as_f64(), Some(40.0));
|
||||
assert_eq!(f["preserveTransparency"], json!(true));
|
||||
// A corrupt remembered value falls back to the default.
|
||||
again.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"contents": "plaid", "opacity": "lots", "mode": 7})));
|
||||
let f = fields(&again);
|
||||
assert_eq!((f["contents"].as_str(), f["mode"].as_str(), f["opacity"].as_f64()), (Some("foreground"), Some("normal"), Some(100.0)));
|
||||
again.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!("not an object")));
|
||||
assert_eq!(fields(&again)["contents"], json!("foreground"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_fields_fail_gracefully() {
|
||||
let mut app = app();
|
||||
for f in [json!({"contents": "plaid"}), json!({"mode": "sideways"}), json!({"opacity": "x"}), json!({"contents": "contentAware"})] {
|
||||
assert!(fill(&mut app, f.clone()).is_err(), "{f}");
|
||||
}
|
||||
assert!(app.ui.dialogs.is_empty(), "OK closes the dialog even when the fill fails");
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 120})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness<'_, PhotocraftApp>, key: Key, m: Modifiers) {
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::ModifiersChanged(m));
|
||||
h.event(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
h.event(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_f5_and_shift_backspace_open_the_dialog() {
|
||||
for key in [Key::F5, Key::Backspace] {
|
||||
let mut h = harness();
|
||||
press(&mut h, key, Modifiers::SHIFT);
|
||||
let open: Vec<_> = h.state().ui.dialogs.iter().filter(|d| owns(&d.fields)).map(|d| d.id).collect();
|
||||
assert_eq!(open.len(), 1, "{key:?} opens the Fill dialog");
|
||||
// Nothing is filled until OK.
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), 0);
|
||||
let id = open[0];
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_opens_the_dialog_and_params_skip_it() {
|
||||
let mut h = harness();
|
||||
let ctx = h.ctx.clone();
|
||||
let r = crate::menus::invoke(h.state_mut(), &ctx, COMMAND, json!({})).unwrap();
|
||||
assert!(r["dialog"].is_u64());
|
||||
// With params (agents, actions), the engine fills directly.
|
||||
crate::menus::invoke(h.state_mut(), &ctx, COMMAND, json!({"contents": "black"})).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), 1);
|
||||
// No document: an error, not a dialog.
|
||||
let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
assert!(crate::menus::invoke(&mut empty, &ctx, COMMAND, json!({})).is_err());
|
||||
assert!(empty.ui.dialogs.is_empty());
|
||||
}
|
||||
@@ -68,6 +68,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("panel-right", include_bytes!("../../../assets/icons/panel-right.svg")),
|
||||
("panels-top-left", include_bytes!("../../../assets/icons/panels-top-left.svg")),
|
||||
("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")),
|
||||
("pencil", include_bytes!("../../../assets/icons/pencil.svg")),
|
||||
("pentagon", include_bytes!("../../../assets/icons/pentagon.svg")),
|
||||
("pipette", include_bytes!("../../../assets/icons/pipette.svg")),
|
||||
("play", include_bytes!("../../../assets/icons/play.svg")),
|
||||
|
||||
@@ -48,6 +48,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
|
||||
Tool::RectMarquee => "square-dashed",
|
||||
Tool::EllipseMarquee => "circle-dashed",
|
||||
Tool::Brush => "brush",
|
||||
Tool::Pencil => "pencil",
|
||||
Tool::Eraser => "eraser",
|
||||
Tool::BackgroundEraser => "eraser-background",
|
||||
Tool::MagicEraser => "eraser-magic",
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod eraser_ui;
|
||||
pub mod export_dialog;
|
||||
pub mod file_open;
|
||||
pub mod file_ui;
|
||||
pub mod fill_ui;
|
||||
pub mod filter_dialog;
|
||||
pub mod gallery_ui;
|
||||
pub mod gpu_canvas;
|
||||
@@ -86,6 +87,7 @@ pub mod preset_panels;
|
||||
pub mod props_layout;
|
||||
pub mod proxy;
|
||||
pub mod puppet_ui;
|
||||
pub mod rasterize_prompt;
|
||||
pub mod retouch_ui;
|
||||
pub mod rulers;
|
||||
pub mod shortcut_dispatch;
|
||||
@@ -1201,6 +1203,9 @@ impl PhotocraftApp {
|
||||
#[cfg(test)]
|
||||
mod input_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pencil_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod marquee_tests;
|
||||
|
||||
|
||||
@@ -312,6 +312,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
// Photoshop's "Select and Mask…" is the engine's select.refineEdge.
|
||||
// Select › Color Range… from the menu: the dialog (with params: the engine directly).
|
||||
"select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})),
|
||||
// Edit › Fill… from the menu or its shortcuts: the Fill dialog (with params: the engine).
|
||||
crate::fill_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
if let Some(Err(why)) = photocraft_engine::commands::find(id).map(|c| (c.enabled)(&app.session)) {
|
||||
return Err(why);
|
||||
}
|
||||
Ok(json!({"dialog": crate::fill_ui::open(app)}))
|
||||
}
|
||||
"select.selectAndMask" => Ok(json!({"dialog": crate::filter_dialog::open(app, "select.refineEdge")})),
|
||||
// Select › Transform Selection from the menu: the interactive box (with params: the engine).
|
||||
"select.transformSelection" if params.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::state::Tool;
|
||||
/// tool's (Photoshop's 10 % the first time). Other tools leave it alone.
|
||||
pub fn sync_tool_smoothing(app: &mut PhotocraftApp) {
|
||||
let tool = app.ui.tool;
|
||||
if !matches!(tool, Tool::Brush | Tool::Eraser) || app.ui.smoothing_tool == Some(tool) {
|
||||
if !matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) || app.ui.smoothing_tool == Some(tool) {
|
||||
return;
|
||||
}
|
||||
let current = app.session.tools.brush.smoothing.clone();
|
||||
|
||||
@@ -25,7 +25,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
|
||||
],
|
||||
&[
|
||||
&[Tool::SpotHealing, Tool::Healing],
|
||||
&[Tool::Brush],
|
||||
&[Tool::Brush, Tool::Pencil],
|
||||
&[Tool::CloneStamp],
|
||||
&[Tool::HistoryBrush],
|
||||
&[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser],
|
||||
@@ -423,7 +423,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
// Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1).
|
||||
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Eraser)) || tool == Tool::QuickSelection {
|
||||
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser)) || tool == Tool::QuickSelection {
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut b = before.clone();
|
||||
let pick = brush_preset_chip(ui, &mut b, &app.session.tools.presets);
|
||||
@@ -479,6 +479,32 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let _ = icons::button(ui, "arrow-left-right", 24.0, false, tl!("Set painting symmetry options"));
|
||||
}
|
||||
// Pencil: Photoshop's options (no hardness or flow: the pencil is always hard).
|
||||
Tool::Pencil => {
|
||||
picked = brush_preset_chip(ui, b, &app.session.tools.presets);
|
||||
crate::brush_picker::settings_toggle(app, ui);
|
||||
widgets::vline(ui, 22.0);
|
||||
if !t.pro {
|
||||
opt_label(ui, "Size");
|
||||
widgets::value_field(ui, &mut b.size, 1.0..=5000.0, "px", 76.0);
|
||||
widgets::vline(ui, 22.0);
|
||||
}
|
||||
opt_label(ui, "Mode");
|
||||
let mut mode = b.mode;
|
||||
let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect();
|
||||
if widgets::dropdown(ui, "pencil-mode", &mut mode, &opts, 96.0) {
|
||||
b.mode = mode;
|
||||
}
|
||||
opt_label(ui, "Opacity");
|
||||
let mut o = b.opacity * 100.0;
|
||||
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", if t.pro { 62.0 } else { 66.0 }).changed() {
|
||||
b.opacity = o / 100.0;
|
||||
}
|
||||
opt_label(ui, "Smoothing");
|
||||
smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 });
|
||||
widgets::vline(ui, 22.0);
|
||||
widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, "Auto Erase");
|
||||
}
|
||||
Tool::Brush | Tool::Eraser => {
|
||||
picked = brush_preset_chip(ui, b, &app.session.tools.presets);
|
||||
crate::brush_picker::settings_toggle(app, ui);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The Pencil tool (#213): aliased strokes through the live-stroke path, Auto Erase, the square
|
||||
//! pixel-grid cursor, ⇧-click lines, Control+Alt resizing and the B group.
|
||||
|
||||
use egui::{Key, Modifiers, Pos2, Rect, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, tool_event};
|
||||
use crate::state::Tool;
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 120, "height": 80, "background": "transparent"})).unwrap();
|
||||
app.run("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "smoothing": {"amount": 0.0}}})).unwrap();
|
||||
app.ui.tool = Tool::Pencil;
|
||||
app
|
||||
}
|
||||
|
||||
fn layer(app: &PhotocraftApp) -> photocraft_engine::doc::Layer {
|
||||
let st = app.session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().clone()
|
||||
}
|
||||
|
||||
fn rgba(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] {
|
||||
layer(app).surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
fn drag(app: &mut PhotocraftApp, pts: &[(f64, f64)], mods: Modifiers) {
|
||||
let (x, y) = pts[0];
|
||||
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods);
|
||||
for &(x, y) in &pts[1..] {
|
||||
tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, mods);
|
||||
}
|
||||
let &(x, y) = pts.last().unwrap();
|
||||
tool_event(app, ToolEvent::Up { x, y }, mods);
|
||||
}
|
||||
|
||||
fn last(app: &PhotocraftApp) -> (String, serde_json::Value) {
|
||||
app.session.journal.last().cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pencil_strokes_are_aliased_live_and_one_undo_step() {
|
||||
let mut app = app();
|
||||
let pts = [(10.0, 10.0), (40.3, 22.7), (70.0, 50.0), (100.0, 30.0)];
|
||||
let (x, y) = pts[0];
|
||||
tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE);
|
||||
for &(x, y) in &pts[1..] {
|
||||
tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, Modifiers::NONE);
|
||||
}
|
||||
// The canvas shows the engine's real dabs while dragging.
|
||||
assert!(app.live_stroke.is_some(), "the Pencil strokes live");
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 30.0 }, Modifiers::NONE);
|
||||
let (id, p) = last(&app);
|
||||
assert_eq!(id, "paint.pencil");
|
||||
assert_eq!(p["autoErase"], json!(false));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), 1);
|
||||
// 8 bits, a soft session brush: still no partial alpha anywhere.
|
||||
let s = layer(&app).surface().unwrap().clone();
|
||||
let mut painted = 0;
|
||||
for y in 0..80 {
|
||||
for x in 0..120 {
|
||||
let a = s.rgba(x, y)[3];
|
||||
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
|
||||
painted += usize::from(a == 1.0);
|
||||
}
|
||||
}
|
||||
assert!(painted > 200, "{painted}");
|
||||
assert_eq!(rgba(&app, 70, 50), [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_erase_paints_the_background_colour_from_foreground_pixels() {
|
||||
let mut app = app();
|
||||
drag(&mut app, &[(10.0, 40.0), (110.0, 40.0)], Modifiers::NONE);
|
||||
app.ui.tool_options.pencil_auto_erase = true;
|
||||
// Starting on the black line: white.
|
||||
drag(&mut app, &[(50.0, 40.0), (50.0, 70.0)], Modifiers::NONE);
|
||||
assert_eq!(rgba(&app, 50, 60), [1.0; 4]);
|
||||
assert_eq!(last(&app).1["autoErase"], json!(true));
|
||||
// Starting off it: black.
|
||||
drag(&mut app, &[(80.0, 10.0), (80.0, 30.0)], Modifiers::NONE);
|
||||
assert_eq!(rgba(&app, 80, 20), [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_draws_a_pencil_line() {
|
||||
let mut app = app();
|
||||
drag(&mut app, &[(10.0, 10.0), (20.0, 10.0)], Modifiers::NONE);
|
||||
drag(&mut app, &[(100.0, 60.0)], Modifiers::SHIFT);
|
||||
let (id, p) = last(&app);
|
||||
assert_eq!(id, "paint.pencil");
|
||||
assert_eq!(p["points"].as_array().unwrap().len(), 2, "{p}");
|
||||
assert_eq!(rgba(&app, 60, 35)[3], 1.0);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_drag_resizes_the_pencil_without_painting() {
|
||||
let mut app = app();
|
||||
let ctrl_alt = Modifiers { alt: true, ctrl: true, ..Default::default() };
|
||||
assert!(crate::brush_resize::applies(Tool::Pencil));
|
||||
drag(&mut app, &[(50.0, 40.0), (60.0, 40.0)], ctrl_alt);
|
||||
assert_eq!(app.session.tools.brush.size, 23.0);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_cursor_sits_on_the_pixel_grid() {
|
||||
let rect = Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(800.0, 600.0));
|
||||
let xf = ViewXform { rect, zoom: 8.0, center: [50.0, 37.5], flip: false };
|
||||
// 1 px at 800 %: the 8-point square of the pixel under the pointer.
|
||||
let r = pencil_cursor_rect(&xf, [10.3, 5.7], 1.0, 1.0);
|
||||
assert_eq!(r, Rect::from_two_pos(xf.to_screen(10.0, 5.0), xf.to_screen(11.0, 6.0)));
|
||||
assert_eq!(r.size(), vec2(8.0, 8.0));
|
||||
// Even sizes centre on the nearest pixel corner; odd ones on the pixel.
|
||||
let r = pencil_cursor_rect(&xf, [10.3, 5.7], 4.0, 1.0);
|
||||
assert_eq!(r, Rect::from_two_pos(xf.to_screen(8.0, 4.0), xf.to_screen(12.0, 8.0)));
|
||||
let r = pencil_cursor_rect(&xf, [10.9, 5.1], 3.0, 1.0);
|
||||
assert_eq!(r, Rect::from_two_pos(xf.to_screen(9.0, 4.0), xf.to_screen(12.0, 7.0)));
|
||||
// At 2× and at an odd zoom the edges land on physical pixels.
|
||||
for (zoom, ppp) in [(3.3, 2.0), (0.5, 2.0), (1.0, 1.0), (13.7, 1.5)] {
|
||||
let xf = ViewXform { rect: Rect::from_min_size(Pos2::new(0.3, 0.7), vec2(800.0, 600.0)), zoom, center: [50.2, 37.9], flip: true };
|
||||
let r = pencil_cursor_rect(&xf, [20.4, 30.6], 5.0, ppp);
|
||||
for v in [r.min.x, r.min.y, r.max.x, r.max.y] {
|
||||
assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "zoom {zoom} ppp {ppp}: {v}");
|
||||
}
|
||||
assert!((r.width() - 5.0 * zoom).abs() <= 1.0 / ppp + 1e-3, "{r:?}");
|
||||
}
|
||||
// Hostile numbers don't panic.
|
||||
let _ = pencil_cursor_rect(&xf, [f64::NAN, f64::INFINITY], f32::NAN, f32::NAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_cycles_brush_and_pencil() {
|
||||
let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 120})).unwrap();
|
||||
app
|
||||
});
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.run_steps(4);
|
||||
let press = |h: &mut Harness<'_, PhotocraftApp>, m: Modifiers| {
|
||||
h.event(egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
h.event(egui::Event::Key { key: Key::B, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
h.run_steps(2);
|
||||
};
|
||||
press(&mut h, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.tool, Tool::Brush);
|
||||
press(&mut h, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.tool, Tool::Pencil);
|
||||
// Use Shift Key for Tool Switch: ⇧B cycles, B keeps the group's tool.
|
||||
h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true);
|
||||
press(&mut h, Modifiers::SHIFT);
|
||||
assert_eq!(h.state().ui.tool, Tool::Brush);
|
||||
press(&mut h, Modifiers::SHIFT);
|
||||
assert_eq!(h.state().ui.tool, Tool::Pencil);
|
||||
press(&mut h, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.tool, Tool::Pencil);
|
||||
assert_eq!(Tool::from_name("pencil"), Some(Tool::Pencil));
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! "Rasterize?" prompt: painting the pixels of a type, shape, Smart Object or fill layer with a
|
||||
//! pixel tool asks first, as Photoshop does ("This type layer must be rasterized before
|
||||
//! proceeding. Its text will no longer be editable. Rasterize the type?"), instead of failing
|
||||
//! with a status-bar message.
|
||||
//!
|
||||
//! The prompt is an ordinary dialog in the UI state (`ui.inspect` shows it, `ui.dialog.confirm`
|
||||
//! and `ui.dialog.cancel` answer it). OK runs the matching `layer.rasterize.*` command, then paints
|
||||
//! where the click was, so rasterizing and painting are two history states, as in Photoshop.
|
||||
//! Cancel does nothing.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ToolEvent;
|
||||
use crate::state::{DialogKind, Tool};
|
||||
|
||||
const MARK: &str = "__rasterize";
|
||||
|
||||
/// A layer kind that must be rasterized before its pixels can be painted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Type,
|
||||
Shape,
|
||||
SmartObject,
|
||||
Fill,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub const ALL: [Kind; 4] = [Kind::Type, Kind::Shape, Kind::SmartObject, Kind::Fill];
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Type => "type",
|
||||
Kind::Shape => "shape",
|
||||
Kind::SmartObject => "smartObject",
|
||||
Kind::Fill => "fill",
|
||||
}
|
||||
}
|
||||
|
||||
/// The command that rasterizes it.
|
||||
pub fn command(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Type => "layer.rasterize.type",
|
||||
Kind::Shape => "layer.rasterize.shape",
|
||||
Kind::SmartObject => "layer.rasterize.smartObject",
|
||||
Kind::Fill => "layer.rasterize.fillContent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's question.
|
||||
pub fn message(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Type => "This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type?",
|
||||
Kind::Shape => "This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape?",
|
||||
Kind::SmartObject => "This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object?",
|
||||
Kind::Fill => "This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer?",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_key(k: &str) -> Option<Kind> {
|
||||
Kind::ALL.into_iter().find(|x| x.key() == k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `tool` paint the layer's pixels (so a vector or Smart Object layer must be rasterized)?
|
||||
pub fn paints_pixels(app: &PhotocraftApp, tool: Tool) -> bool {
|
||||
tool.is_brushlike() || matches!(tool, Tool::PaintBucket | Tool::MagicEraser) || (tool == Tool::Gradient && app.ui.tool_options.gradient_classic)
|
||||
}
|
||||
|
||||
/// The active layer's kind when painting its pixels needs rasterizing first: the tool targets
|
||||
/// the layer's pixels (not its mask, an alpha channel or the Quick Mask) and the layer is a
|
||||
/// type, shape, Smart Object or fill layer that isn't locked.
|
||||
pub fn needed(app: &PhotocraftApp) -> Option<(Kind, photocraft_doc::LayerId)> {
|
||||
if crate::canvas::paint_target(app) != json!("pixels") {
|
||||
return None;
|
||||
}
|
||||
let st = app.session.active()?;
|
||||
let id = st.active_layer?;
|
||||
let l = st.doc.layer(id)?;
|
||||
if l.locks.all || l.locks.pixels {
|
||||
return None;
|
||||
}
|
||||
use photocraft_doc::LayerContent as C;
|
||||
let kind = match &l.content {
|
||||
C::Text(_) => Kind::Type,
|
||||
C::Shape(_) => Kind::Shape,
|
||||
C::Smart(_) => Kind::SmartObject,
|
||||
C::Fill(_) => Kind::Fill,
|
||||
_ => return None,
|
||||
};
|
||||
Some((kind, id))
|
||||
}
|
||||
|
||||
/// A tool's pointer press: when it would paint a layer that must be rasterized first, park it
|
||||
/// behind the prompt and return true (the tool must not see the press).
|
||||
pub fn intercept(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64, pressure: f32) -> bool {
|
||||
if !paints_pixels(app, tool) {
|
||||
return false;
|
||||
}
|
||||
let Some((kind, layer)) = needed(app) else { return false };
|
||||
if !app.ui.dialogs.iter().any(|d| owns(&d.fields)) {
|
||||
open(app, kind, layer, tool, [x, y, f64::from(pressure)]);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Open the prompt for `layer`; OK rasterizes it, then `tool` paints at `at` (x, y, pressure).
|
||||
pub fn open(app: &mut PhotocraftApp, kind: Kind, layer: photocraft_doc::LayerId, tool: Tool, at: [f64; 3]) -> u64 {
|
||||
let mut f = Map::new();
|
||||
f.insert(MARK.into(), json!(kind.key()));
|
||||
f.insert("__command".into(), json!(kind.command()));
|
||||
f.insert("__label".into(), json!("PhotoCraft"));
|
||||
f.insert("message".into(), json!(kind.message()));
|
||||
f.insert("layer".into(), json!(layer.0));
|
||||
f.insert("tool".into(), json!(format!("{tool:?}")));
|
||||
f.insert("at".into(), json!(at));
|
||||
app.ui.open_dialog(DialogKind::Command, f)
|
||||
}
|
||||
|
||||
/// Is this dialog the prompt?
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.contains_key(MARK)
|
||||
}
|
||||
|
||||
/// The prompt's body: the question, with a caution mark.
|
||||
pub fn body(ui: &mut egui::Ui, f: &Map<String, Value>) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let text = f.get("message").and_then(Value::as_str).unwrap_or("This layer must be rasterized before proceeding.");
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 12.0;
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(32.0, 32.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui, r, "triangle-alert", 30.0, t.warning);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).color(t.text).size(13.0)).wrap());
|
||||
});
|
||||
}
|
||||
|
||||
/// OK: rasterize, then paint at the click (two history states).
|
||||
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
let kind = f.get(MARK).and_then(Value::as_str).and_then(Kind::from_key).ok_or("not a rasterize prompt")?;
|
||||
let layer = f.get("layer").and_then(Value::as_u64).ok_or("the prompt has no layer")?;
|
||||
let r = app.run(kind.command(), json!({ "layer": layer }))?;
|
||||
let tool = f.get("tool").and_then(Value::as_str).and_then(Tool::from_name);
|
||||
let at: Vec<f64> = f.get("at").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let (Some(tool), Some(&x), Some(&y)) = (tool, at.first(), at.get(1)) else {
|
||||
return Ok(json!({ "rasterized": r, "painted": false }));
|
||||
};
|
||||
let pressure = at.get(2).copied().unwrap_or(1.0) as f32;
|
||||
// The same tool, layer and point as when the prompt opened.
|
||||
app.ui.tool = tool;
|
||||
let before = app.session.active().map(|st| st.history.past_len());
|
||||
if app.session.active().and_then(|st| st.active_layer) == Some(photocraft_doc::LayerId(layer)) && x.is_finite() && y.is_finite() {
|
||||
crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: pressure.clamp(0.0, 1.0) }, egui::Modifiers::NONE);
|
||||
crate::canvas::tool_event(app, ToolEvent::Up { x, y }, egui::Modifiers::NONE);
|
||||
}
|
||||
let painted = app.session.active().map(|st| st.history.past_len()) != before;
|
||||
Ok(json!({ "rasterized": r, "painted": painted }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rasterize_prompt_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,167 @@
|
||||
use egui::Modifiers;
|
||||
use photocraft_doc::LayerContent;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::canvas::tool_event;
|
||||
|
||||
/// A document whose active layer is a `kind` layer over a white Background.
|
||||
fn app_with(kind: Kind) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 120, "height": 80})).unwrap();
|
||||
match kind {
|
||||
Kind::Type => app.run("type.create", json!({"x": 10, "y": 40, "text": "Hello"})).map(|_| ()),
|
||||
Kind::Shape => app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 60, 40], "fill": "#ff0000"})).map(|_| ()),
|
||||
Kind::SmartObject => {
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).map(|_| ())
|
||||
}
|
||||
Kind::Fill => app.run("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).map(|_| ()),
|
||||
}
|
||||
.unwrap();
|
||||
app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn active_content(app: &PhotocraftApp) -> &'static str {
|
||||
let st = app.session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().content.kind_name()
|
||||
}
|
||||
|
||||
fn past(app: &PhotocraftApp) -> usize {
|
||||
app.session.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
fn click(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE);
|
||||
}
|
||||
|
||||
fn prompt(app: &PhotocraftApp) -> Option<&crate::state::Dialog> {
|
||||
app.ui.dialogs.iter().find(|d| owns(&d.fields))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_rasterizes_then_paints_as_two_history_states() {
|
||||
for kind in Kind::ALL {
|
||||
for tool in [Tool::Brush, Tool::Pencil] {
|
||||
let mut app = app_with(kind);
|
||||
app.ui.tool = tool;
|
||||
let before = past(&app);
|
||||
click(&mut app, 30.0, 20.0);
|
||||
let d = prompt(&app).unwrap_or_else(|| panic!("{kind:?}/{tool:?}: no prompt"));
|
||||
assert_eq!(d.fields["message"], json!(kind.message()));
|
||||
assert_eq!(past(&app), before, "nothing happens until the answer");
|
||||
let id = d.id;
|
||||
let r = crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(r["painted"], json!(true), "{kind:?}/{tool:?}");
|
||||
assert_eq!(active_content(&app), "Pixel");
|
||||
assert_eq!(past(&app), before + 2, "{kind:?}/{tool:?}: rasterize, then paint");
|
||||
let st = app.session.active().unwrap();
|
||||
let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(30, 20);
|
||||
assert!(c[2] > 0.99 && c[0] < 0.01, "{kind:?}/{tool:?}: painted blue at the click: {c:?}");
|
||||
// One undo takes the paint back; the layer stays rasterized.
|
||||
app.session.undo();
|
||||
assert_eq!(active_content(&app), "Pixel");
|
||||
app.session.undo();
|
||||
assert_eq!(active_content(&app), kind_name(kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_name(k: Kind) -> &'static str {
|
||||
match k {
|
||||
Kind::Type => "Type",
|
||||
Kind::Shape => "Shape",
|
||||
Kind::SmartObject => "Smart Object",
|
||||
Kind::Fill => "Fill",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_does_nothing() {
|
||||
let mut app = app_with(Kind::Type);
|
||||
app.ui.tool = Tool::Brush;
|
||||
let before = (past(&app), app.session.active().unwrap().doc.clone());
|
||||
click(&mut app, 30.0, 20.0);
|
||||
let id = prompt(&app).unwrap().id;
|
||||
// A second press while it is up doesn't stack another prompt.
|
||||
click(&mut app, 40.0, 20.0);
|
||||
assert_eq!(app.ui.dialogs.len(), 1);
|
||||
app.ui.close_dialog(id).unwrap();
|
||||
assert_eq!(past(&app), before.0);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &before.1) || *app.session.active().unwrap().doc == *before.1);
|
||||
assert_eq!(active_content(&app), "Type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_pixel_painting_asks() {
|
||||
let mut app = app_with(Kind::Type);
|
||||
for tool in [Tool::Move, Tool::RectMarquee, Tool::Eyedropper, Tool::Hand, Tool::Type] {
|
||||
app.ui.tool = tool;
|
||||
click(&mut app, 30.0, 20.0);
|
||||
assert!(prompt(&app).is_none(), "{tool:?} must not ask");
|
||||
app.ui.text_edit = None;
|
||||
}
|
||||
for tool in [Tool::Eraser, Tool::CloneStamp, Tool::PaintBucket, Tool::Smudge] {
|
||||
app.ui.tool = tool;
|
||||
click(&mut app, 30.0, 20.0);
|
||||
let id = prompt(&app).unwrap_or_else(|| panic!("{tool:?} asks")).id;
|
||||
app.ui.close_dialog(id);
|
||||
}
|
||||
// ⌥-click with the Clone Stamp sets the source.
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 20.0, pressure: 1.0 }, Modifiers::ALT);
|
||||
assert!(prompt(&app).is_none());
|
||||
// Painting the layer's mask needs no rasterizing.
|
||||
app.run("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
app.ui.mask_target = true;
|
||||
app.ui.tool = Tool::Brush;
|
||||
click(&mut app, 30.0, 20.0);
|
||||
assert!(prompt(&app).is_none());
|
||||
assert!(app.session.active().unwrap().doc.layers.iter().any(|l| matches!(l.content, LayerContent::Text(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_or_bad_prompt_fails_gracefully() {
|
||||
let mut app = app_with(Kind::Type);
|
||||
app.ui.tool = Tool::Brush;
|
||||
click(&mut app, 30.0, 20.0);
|
||||
let id = prompt(&app).unwrap().id;
|
||||
// The layer was rasterized elsewhere meanwhile: OK reports the error, nothing panics.
|
||||
app.run("layer.rasterize.type", json!({})).unwrap();
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
// Garbage fields.
|
||||
for f in [json!({"__rasterize": "plaid", "layer": 1}), json!({"__rasterize": "type"}), json!({"__rasterize": "type", "layer": 999_999})] {
|
||||
let id = app.ui.open_dialog(crate::state::DialogKind::Command, f.as_object().unwrap().clone());
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{f}");
|
||||
}
|
||||
// A bad tool or point rasterizes without painting.
|
||||
let mut app = app_with(Kind::Fill);
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
let id = open(&mut app, Kind::Fill, layer, Tool::Brush, [f64::NAN, 1.0, 1.0]);
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("tool".into(), json!("no such tool"));
|
||||
let r = crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(r["painted"], json!(false));
|
||||
assert_eq!(active_content(&app), "Pixel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_channel_sees_and_confirms_the_prompt() {
|
||||
let mut app = app_with(Kind::Shape);
|
||||
let ctx = egui::Context::default();
|
||||
app.ui.tool = Tool::Pencil;
|
||||
let call = |app: &mut PhotocraftApp, method: &str, params: serde_json::Value| {
|
||||
let (req, _rx) = crate::control::ControlRequest::new(method, params);
|
||||
match crate::control::handle(app, &ctx, &req) {
|
||||
crate::control::Outcome::Done(v) => v,
|
||||
_ => panic!("deferred"),
|
||||
}
|
||||
};
|
||||
call(&mut app, "ui.pointer", json!({"events": [{"kind": "down", "x": 30, "y": 20}, {"kind": "up", "x": 30, "y": 20}]}));
|
||||
let id = prompt(&app).map(|d| d.id).expect("prompt");
|
||||
let r = call(&mut app, "ui.dialog.confirm", json!({"dialog": id}));
|
||||
assert_eq!(r["ok"], json!(true), "{r}");
|
||||
assert_eq!(active_content(&app), "Pixel");
|
||||
}
|
||||
@@ -129,7 +129,7 @@ fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32) {
|
||||
|
||||
/// Options bar for the retouching and smart-selection tools. Returns false for other tools.
|
||||
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool {
|
||||
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection) || matches!(tool, Tool::Brush | Tool::Eraser) {
|
||||
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection) || matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) {
|
||||
return false;
|
||||
}
|
||||
let o = &mut app.ui.tool_options;
|
||||
|
||||
@@ -39,6 +39,10 @@ pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> {
|
||||
ctx.data_mut(|d| d.remove_temp::<Vec<(String, Outcome)>>(log_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Alternative default shortcuts Photoshop gives some commands besides the one its menu shows:
|
||||
/// (command, shortcut). Edit › Fill… is Shift+F5 and also Shift+Backspace (Shift+Delete on a Mac).
|
||||
pub const SECONDARY: &[(&str, &str)] = &[("edit.fill", "Shift+Backspace")];
|
||||
|
||||
/// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command:
|
||||
/// command and shell shortcuts, then the menu catalogue's for live items without their own,
|
||||
/// then Edit › Keyboard Shortcuts assignments to any other menu item. D and X
|
||||
@@ -67,8 +71,10 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> {
|
||||
&& !crate::hold_keys::is_temporary(id)
|
||||
})
|
||||
.map(|(id, sc)| (id.as_str(), Some(sc.as_str())));
|
||||
// Photoshop's second shortcuts, kept while the command's main one is the default.
|
||||
let secondary = SECONDARY.iter().filter(|(id, _)| !prefs.shortcuts.contains_key(*id)).map(|&(id, sc)| (id, Some(sc)));
|
||||
let mut all: Vec<(String, KeyboardShortcut)> = Vec::new();
|
||||
for (id, sc) in ui.chain(engine).chain(catalog).chain(overrides) {
|
||||
for (id, sc) in ui.chain(engine).chain(catalog).chain(overrides).chain(secondary) {
|
||||
let Some(sc) = sc.and_then(parse) else { continue };
|
||||
if !all.iter().any(|(_, b)| *b == sc) {
|
||||
all.push((id.to_string(), sc));
|
||||
|
||||
@@ -265,6 +265,8 @@ fn common_shortcuts_work_after_clicking_panels() {
|
||||
h.run_steps(2);
|
||||
}
|
||||
if !text {
|
||||
// From another group (B again would cycle to the Pencil).
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
press(&mut h, "B");
|
||||
assert_eq!(h.state().ui.tool, Tool::Brush, "{place:?}: B picks the Brush");
|
||||
press(&mut h, "V");
|
||||
|
||||
@@ -17,6 +17,7 @@ pub enum Tool {
|
||||
Note,
|
||||
Count,
|
||||
Brush,
|
||||
Pencil,
|
||||
Eraser,
|
||||
BackgroundEraser,
|
||||
MagicEraser,
|
||||
@@ -50,7 +51,7 @@ pub enum Tool {
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub const ALL: [Tool; 42] = [
|
||||
pub const ALL: [Tool; 43] = [
|
||||
Tool::Move,
|
||||
Tool::RectMarquee,
|
||||
Tool::EllipseMarquee,
|
||||
@@ -63,6 +64,7 @@ impl Tool {
|
||||
Tool::Note,
|
||||
Tool::Count,
|
||||
Tool::Brush,
|
||||
Tool::Pencil,
|
||||
Tool::Eraser,
|
||||
Tool::BackgroundEraser,
|
||||
Tool::MagicEraser,
|
||||
@@ -101,6 +103,7 @@ impl Tool {
|
||||
Tool::RectMarquee => "Rectangular Marquee Tool",
|
||||
Tool::EllipseMarquee => "Elliptical Marquee Tool",
|
||||
Tool::Brush => "Brush Tool",
|
||||
Tool::Pencil => "Pencil Tool",
|
||||
Tool::Eraser => "Eraser Tool",
|
||||
Tool::BackgroundEraser => "Background Eraser Tool",
|
||||
Tool::MagicEraser => "Magic Eraser Tool",
|
||||
@@ -146,6 +149,7 @@ impl Tool {
|
||||
matches!(
|
||||
self,
|
||||
Tool::Brush
|
||||
| Tool::Pencil
|
||||
| Tool::Eraser
|
||||
| Tool::BackgroundEraser
|
||||
| Tool::SpotHealing
|
||||
@@ -165,7 +169,7 @@ impl Tool {
|
||||
match self {
|
||||
Tool::Move => 'V',
|
||||
Tool::RectMarquee | Tool::EllipseMarquee => 'M',
|
||||
Tool::Brush => 'B',
|
||||
Tool::Brush | Tool::Pencil => 'B',
|
||||
Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E',
|
||||
Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I',
|
||||
Tool::Lasso | Tool::PolygonLasso => 'L',
|
||||
@@ -384,6 +388,9 @@ pub struct ToolOptions {
|
||||
/// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle).
|
||||
#[serde(default = "yes")]
|
||||
pub zoom_scrubby: bool,
|
||||
/// Pencil › Auto Erase: a stroke that starts on the foreground colour paints the background colour.
|
||||
#[serde(default)]
|
||||
pub pencil_auto_erase: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -453,6 +460,7 @@ impl Default for ToolOptions {
|
||||
bg_tolerance: 50.0,
|
||||
bg_protect_fg: false,
|
||||
zoom_scrubby: true,
|
||||
pencil_auto_erase: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
|
||||
- `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}`
|
||||
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).
|
||||
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
- `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); `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing)
|
||||
- `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}`
|
||||
|
||||
+6
-6
@@ -8,8 +8,8 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 126/256, round trip ≥ 256/256 |
|
||||
| [Tools](#tools) | 0 | 16 | 14 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 132 (target 0) |
|
||||
| [Tools](#tools) | 3 | 14 | 13 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 133 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 2 | 1 | 4 | never-crash lints on 26 of 26 crates; panic_hunt present |
|
||||
@@ -118,12 +118,12 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.pencil exists (brush_cmds.rs:322) with an aliasing test; no Tool::Pencil (state.rs:7) |
|
||||
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs |
|
||||
| TOOL-213-2 | Mixer Brush tool on the toolbar | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.mixerBrush (brush_cmds.rs:337) + panel options; no Tool variant |
|
||||
| TOOL-213-3 | Patch tool with a live preview of the patched area | missing | [#213](https://github.com/storytold/photocraft/issues/213) | No patch command or tool |
|
||||
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | missing | [#213](https://github.com/storytold/photocraft/issues/213) | inpaint::complete only used by spot healing (retouch_cmds.rs:386) |
|
||||
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Rasterize only in the layer menus (layer_menu_ui.rs:40) |
|
||||
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | partial | [#213](https://github.com/storytold/photocraft/issues/213) | edit.fill: color or pattern + opacity, Shift+F5 (commands.rs:263-267); no blend/history/CA/persist |
|
||||
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |
|
||||
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace |
|
||||
| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Live stroke only for Brush and Eraser (canvas.rs:1684) |
|
||||
| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Panel W/H + overlay settings (preset_panels.rs:946-1015); no link, overlay never drawn |
|
||||
| TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | missing | [#213](https://github.com/storytold/photocraft/issues/213) | sample_eyedropper (canvas.rs:1552) draws no ring |
|
||||
@@ -151,7 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 61 of 132** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 61 of 133** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `general.use_legacy_free_transform`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.overscroll`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ order = 3
|
||||
[[item]]
|
||||
id = "TOOL-213-1"
|
||||
target = "Pencil tool: aliased 1 px painting, square pixel-aligned cursor"
|
||||
status = "partial"
|
||||
status = "done"
|
||||
issue = 213
|
||||
note = "paint.pencil exists (brush_cmds.rs:322) with an aliasing test; no Tool::Pencil (state.rs:7)"
|
||||
note = "Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-2"
|
||||
@@ -36,16 +36,16 @@ note = "inpaint::complete only used by spot healing (retouch_cmds.rs:386)"
|
||||
[[item]]
|
||||
id = "TOOL-213-5"
|
||||
target = "Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 213
|
||||
note = "Rasterize only in the layer menus (layer_menu_ui.rs:40)"
|
||||
note = "rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-6"
|
||||
target = "Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace"
|
||||
status = "partial"
|
||||
status = "done"
|
||||
issue = 213
|
||||
note = "edit.fill: color or pattern + opacity, Shift+F5 (commands.rs:263-267); no blend/history/CA/persist"
|
||||
note = "fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-7"
|
||||
|
||||
Reference in new issue
Block a user