mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 19:56:50 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2980771e | ||
|
|
c5703f5632 |
No files matched your search
@@ -253,14 +253,23 @@ fn doc_cmyk_space(s: &Session) -> Option<std::sync::Arc<photocraft_color::conver
|
||||
}
|
||||
|
||||
/// Stroke with a resolved brush onto the target layer (pixels or mask).
|
||||
fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>, auto_erase: bool) -> Result<Value> {
|
||||
fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>, auto_erase: bool) -> Result<Value> {
|
||||
let bg = s.tools.background;
|
||||
let fg = brush.color;
|
||||
let symmetry = s.active().and_then(|st| st.symmetry.clone());
|
||||
let (id, brush, zoom) = stroke_target(s, p, brush)?;
|
||||
let cmyk = doc_cmyk_space(s);
|
||||
// The live stroke the canvas already rendered for exactly these params: take its pixels
|
||||
// instead of rendering the whole stroke again (the hitch at pen-up).
|
||||
let live = s.live_commit.take().filter(|l| l.matches(s, cmd, p, &pts));
|
||||
let from_live = live.is_some();
|
||||
let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
s.edit(label, |doc, _| {
|
||||
if let Some(l) = live {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
*surf = l.surface;
|
||||
return Ok(l.bounds);
|
||||
}
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let mut brush = brush;
|
||||
@@ -295,7 +304,11 @@ fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pt
|
||||
Ok(damage)
|
||||
})
|
||||
})?;
|
||||
Ok(damage_json(s, dmg))
|
||||
let mut r = damage_json(s, dmg);
|
||||
if from_live {
|
||||
r["fromLive"] = json!(true);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Pencil Auto Erase: a stroke that starts on a pixel of the foreground colour paints the
|
||||
@@ -377,7 +390,7 @@ pub fn paint_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = parse_points(p, "paint.stroke")?;
|
||||
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
|
||||
let label = if brush.erase { "Eraser" } else { "Brush Tool" };
|
||||
stroke_with(s, p, label, brush, pts, false)
|
||||
stroke_with(s, "paint.stroke", p, label, brush, pts, false)
|
||||
}
|
||||
|
||||
/// A `paint.stroke` rendered while it is drawn, onto a copy of the active document, so the canvas
|
||||
@@ -405,6 +418,44 @@ pub struct LiveStroke {
|
||||
/// The document's CMYK profile the commit enters (`stroke_with`), so the preview shows the
|
||||
/// pixels a `paint.stroke` with `seed` will write.
|
||||
cmyk: Option<std::sync::Arc<photocraft_color::convert::CmykSpace>>,
|
||||
/// Every point rendered so far, in order.
|
||||
points: Vec<StrokePoint>,
|
||||
/// The document and revision the stroke started on.
|
||||
base: (photocraft_doc::DocId, u64),
|
||||
}
|
||||
|
||||
/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`]): when the
|
||||
/// committing `paint.stroke` / `paint.pencil` matches it (same command, document revision, params,
|
||||
/// seed and points), the command takes these pixels instead of rendering the stroke again.
|
||||
pub struct LiveCommit {
|
||||
cmd: String,
|
||||
base: (photocraft_doc::DocId, u64),
|
||||
params: Value,
|
||||
seed: u64,
|
||||
points: Vec<StrokePoint>,
|
||||
surface: Surface,
|
||||
bounds: Rect,
|
||||
}
|
||||
|
||||
impl LiveCommit {
|
||||
/// The params that decide the pixels, without the points and seed (compared separately).
|
||||
fn settings(p: &Value) -> Value {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.remove("points");
|
||||
o.remove("seed");
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool {
|
||||
let base = s.active().map(|st| (st.doc.id, st.revision));
|
||||
self.cmd == cmd
|
||||
&& base == Some(self.base)
|
||||
&& p.get("seed").and_then(Value::as_u64) == Some(self.seed)
|
||||
&& self.points == pts
|
||||
&& Self::settings(p) == Self::settings(&self.params)
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveStroke {
|
||||
@@ -426,7 +477,9 @@ impl LiveStroke {
|
||||
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 st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let base = (st.doc.id, st.revision);
|
||||
let mut doc = (*st.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);
|
||||
@@ -454,11 +507,20 @@ impl LiveStroke {
|
||||
params: p.clone(),
|
||||
tail: Rect::EMPTY,
|
||||
cmyk: doc_cmyk_space(s),
|
||||
points: Vec::new(),
|
||||
base,
|
||||
};
|
||||
live.push(&pts)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// The stroke as its commit would take it over (see [`LiveCommit`]).
|
||||
pub fn into_commit(mut self) -> Result<LiveCommit> {
|
||||
let bounds = self.bounds();
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: self.seed, points: self.points, surface: surf.clone(), bounds })
|
||||
}
|
||||
|
||||
/// Everything the stroke has touched so far.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
let bounds = self.renderer.bounds().union(&self.tail);
|
||||
@@ -478,6 +540,7 @@ impl LiveStroke {
|
||||
/// Invalid coordinates reject the whole batch without changing the preview.
|
||||
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
|
||||
check_coords(pts, &self.cmd)?;
|
||||
self.points.extend_from_slice(pts);
|
||||
let cmyk = self.cmyk.clone();
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
self.renderer.push(pts);
|
||||
@@ -539,7 +602,7 @@ fn pencil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = parse_points(p, "paint.pencil")?;
|
||||
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))
|
||||
stroke_with(s, "paint.pencil", p, label, brush, pts, flag(p, "autoErase", false))
|
||||
}
|
||||
|
||||
fn pct(p: &Value, k: &str, d: f32) -> f32 {
|
||||
|
||||
@@ -740,3 +740,78 @@ fn strokes_convert_through_the_documents_cmyk_profile() {
|
||||
let untagged = ink(&surface(&plain));
|
||||
assert!(tagged != untagged, "the document's CMYK profile must convert the painted ink: {tagged:?} vs {untagged:?}");
|
||||
}
|
||||
|
||||
/// A live stroke over `pts` (pushed in chunks, as the canvas does) and its commit params.
|
||||
fn live_and_commit(s: &Session, cmd: &str, base: Value, pts: &[[f64; 3]]) -> (LiveStroke, Value) {
|
||||
let mut p = base;
|
||||
p["points"] = json!([pts[0]]);
|
||||
let mut live = LiveStroke::begin_with(s, cmd, &p).unwrap();
|
||||
for c in pts[1..].chunks(2) {
|
||||
let sp: Vec<StrokePoint> = c.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
|
||||
live.push(&sp).unwrap();
|
||||
}
|
||||
p["points"] = json!(pts);
|
||||
p["seed"] = json!(live.seed);
|
||||
(live, p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_takes_the_live_strokes_pixels_and_they_equal_a_fresh_render() {
|
||||
let pts = [[10.0, 50.0, 1.0], [60.0, 30.0, 0.7], [120.0, 60.0, 0.9], [180.0, 40.0, 0.4], [150.0, 80.0, 1.0]];
|
||||
for (cmd, base) in [
|
||||
("paint.stroke", json!({"brush": {"size": 18, "hardness": 0.0, "flow": 0.5}, "smoothing": 0.5, "target": "pixels"})),
|
||||
("paint.stroke", json!({"preset": "Spatter", "smoothing": 0.0, "target": "pixels"})),
|
||||
("paint.stroke", json!({"brush": {"size": 12}, "erase": true, "target": "pixels"})),
|
||||
("paint.pencil", json!({"smoothing": 0.0})),
|
||||
] {
|
||||
let mut s = session(200, 100);
|
||||
let (live, p) = live_and_commit(&s, cmd, base.clone(), &pts);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.offer_live_stroke(live).unwrap();
|
||||
let r = s.execute(cmd, p.clone()).unwrap();
|
||||
s.clear_live_stroke();
|
||||
assert_eq!(r["fromLive"], true, "{cmd} {base}: the commit took the live pixels");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step");
|
||||
// The journal's replay renders from scratch and must give the same pixels.
|
||||
let mut fresh = session(200, 100);
|
||||
let r = fresh.execute(cmd, p).unwrap();
|
||||
assert!(r.get("fromLive").is_none());
|
||||
assert!(same_pixels(&surface(&s), &surface(&fresh), Rect::new(0, 0, 200, 100)), "{cmd} {base}");
|
||||
// Undo restores the pre-stroke pixels.
|
||||
s.undo();
|
||||
assert!(same_pixels(&surface(&s), &surface(&session(200, 100)), Rect::new(0, 0, 200, 100)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offer_that_does_not_match_the_commit_is_ignored() {
|
||||
let pts = [[10.0, 50.0, 1.0], [60.0, 30.0, 0.7], [120.0, 60.0, 0.9]];
|
||||
let base = json!({"brush": {"size": 10}, "smoothing": 0.0, "target": "pixels"});
|
||||
// Different points.
|
||||
let mut s = session(200, 100);
|
||||
let (live, mut p) = live_and_commit(&s, "paint.stroke", base.clone(), &pts);
|
||||
p["points"] = json!([[10.0, 50.0, 1.0], [190.0, 90.0, 1.0]]);
|
||||
s.offer_live_stroke(live).unwrap();
|
||||
let r = s.execute("paint.stroke", p.clone()).unwrap();
|
||||
s.clear_live_stroke();
|
||||
assert!(r.get("fromLive").is_none());
|
||||
let mut fresh = session(200, 100);
|
||||
fresh.execute("paint.stroke", p).unwrap();
|
||||
assert!(same_pixels(&surface(&s), &surface(&fresh), Rect::new(0, 0, 200, 100)));
|
||||
// The document changed after the stroke began.
|
||||
let mut s = session(200, 100);
|
||||
let (live, p) = live_and_commit(&s, "paint.stroke", base.clone(), &pts);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.offer_live_stroke(live).unwrap();
|
||||
let r = s.execute("paint.stroke", p).unwrap();
|
||||
s.clear_live_stroke();
|
||||
assert!(r.get("fromLive").is_none(), "a stale offer is never used");
|
||||
// Another command leaves an offer untaken, and a later unrelated stroke can't use it.
|
||||
let mut s = session(200, 100);
|
||||
let (live, _) = live_and_commit(&s, "paint.stroke", base.clone(), &pts);
|
||||
s.offer_live_stroke(live).unwrap();
|
||||
let mut other = base;
|
||||
other["points"] = json!([[5.0, 5.0, 1.0], [40.0, 5.0, 1.0]]);
|
||||
let r = s.execute("paint.stroke", other).unwrap();
|
||||
assert!(r.get("fromLive").is_none());
|
||||
}
|
||||
@@ -372,6 +372,9 @@ pub struct Session {
|
||||
pub authorize: Option<fn(&str, &serde_json::Value) -> Result<()>>,
|
||||
/// Background jobs (see [`jobs`]).
|
||||
jobs: jobs::Jobs,
|
||||
/// A finished live stroke offered to the `paint.stroke` / `paint.pencil` that commits it
|
||||
/// ([`Session::offer_live_stroke`]).
|
||||
pub(crate) live_commit: Option<brush_cmds::LiveCommit>,
|
||||
}
|
||||
|
||||
/// Move item `i` of `v` to position `to`, clamped to the end. Returns where it went; `None` when
|
||||
@@ -475,6 +478,20 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer a finished live stroke to the next command: a `paint.stroke` / `paint.pencil` whose
|
||||
/// params match it exactly takes its pixels instead of rendering the stroke again (its result
|
||||
/// then says `"fromLive": true`); any other command drops the offer. The command still runs
|
||||
/// through the normal dispatch, so authorization, the journal and history are unchanged.
|
||||
pub fn offer_live_stroke(&mut self, live: brush_cmds::LiveStroke) -> Result<()> {
|
||||
self.live_commit = Some(live.into_commit()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw an offer the command didn't take.
|
||||
pub fn clear_live_stroke(&mut self) {
|
||||
self.live_commit = None;
|
||||
}
|
||||
|
||||
/// Is the command currently runnable? (drives menu enablement)
|
||||
pub fn is_enabled(&self, id: &str) -> bool {
|
||||
self.is_enabled_with(id, &Value::Null)
|
||||
|
||||
@@ -107,6 +107,26 @@ impl Bench {
|
||||
|
||||
/// The canvas refresh after an edit (`canvas::ensure_gpu`): the damage rect grown by the
|
||||
/// effect reach when the session reports one, else everything; waits for the GPU.
|
||||
/// Re-composite only `r` of the active document (what the canvas hadn't shown yet).
|
||||
fn refresh_rect(&self, s: &Session, r: photocraft_geom::Rect) -> Res<f64> {
|
||||
let st = s.active().ok_or("no document")?;
|
||||
let doc = &st.doc;
|
||||
let r = r.intersect(&doc.bounds());
|
||||
let t = Instant::now();
|
||||
if !r.is_empty() {
|
||||
match &self.gpu {
|
||||
Some((g, rs)) => {
|
||||
g.refresh(doc.id.0, doc, Some(r), None);
|
||||
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
std::hint::black_box(photocraft_compose::render(doc, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ms(t))
|
||||
}
|
||||
|
||||
fn refresh(&self, s: &Session, full: bool) -> Res<f64> {
|
||||
let st = s.active().ok_or("no document")?;
|
||||
let doc = &st.doc;
|
||||
@@ -860,4 +880,46 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
// P32: releasing a long stroke. The canvas draws it live (untimed here); the release offers
|
||||
// those pixels to the commit and re-composites only what the canvas hadn't shown yet.
|
||||
let release = "brush release: 600-point stroke, 200 px soft, 24 MP";
|
||||
if b.wanted(release) {
|
||||
let (w, h) = sz.big_photo;
|
||||
let mut s = Session::new();
|
||||
if let Err(e) = exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))
|
||||
.and_then(|_| exec(&mut s, "layer.new.layer", json!({"name": "paint"})))
|
||||
{
|
||||
b.errors.push((release.into(), e));
|
||||
return;
|
||||
}
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps.min(5);
|
||||
b.time(release, &mut s, reps, false, |b, s, i| {
|
||||
use photocraft_engine::paint::StrokePoint;
|
||||
let (fw, fh) = (f64::from(w), f64::from(h));
|
||||
let pts: Vec<[f64; 3]> = (0..600)
|
||||
.map(|k| {
|
||||
let t = k as f64 / 599.0;
|
||||
[fw * (0.1 + 0.8 * t), fh * (0.5 + 0.35 * (t * 12.0 + i as f64).sin()), 0.6 + 0.4 * (t * 7.0).sin().abs()]
|
||||
})
|
||||
.collect();
|
||||
let mut p = json!({"points": [pts[0]], "size": 200, "hardness": 0.0, "color": "#c03020", "smoothing": 0.0});
|
||||
let mut live = photocraft_engine::brush_cmds::LiveStroke::begin(s, &p).map_err(|e| e.to_string())?;
|
||||
for c in pts[1..].chunks(4) {
|
||||
let sp: Vec<StrokePoint> = c.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
|
||||
live.push(&sp).map_err(|e| e.to_string())?;
|
||||
}
|
||||
p["points"] = json!(pts);
|
||||
p["seed"] = json!(live.seed);
|
||||
let t = Instant::now();
|
||||
s.offer_live_stroke(live).map_err(|e| e.to_string())?;
|
||||
let r = exec(s, "paint.stroke", p);
|
||||
s.clear_live_stroke();
|
||||
if r?.get("fromLive").and_then(Value::as_bool) != Some(true) {
|
||||
return Err("the release rendered the stroke again".into());
|
||||
}
|
||||
// The canvas already shows every step of the stroke: nothing left to re-composite.
|
||||
Ok(ms(t) + b.refresh_rect(s, photocraft_geom::Rect::EMPTY)?)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,29 @@ impl LiveStroke {
|
||||
}
|
||||
}
|
||||
|
||||
/// A live stroke whose pixels its commit took over (`"fromLive"`): document `doc` at `revision`
|
||||
/// holds exactly the live stroke's last step, so a cache that showed step `n` of it only misses
|
||||
/// the steps after `n` (step 0 = the document before the stroke: everything).
|
||||
pub(crate) struct CommittedStroke {
|
||||
pub(crate) doc: photocraft_doc::DocId,
|
||||
pub(crate) revision: u64,
|
||||
key: u64,
|
||||
damage: Vec<DRect>,
|
||||
}
|
||||
|
||||
impl CommittedStroke {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(key: u64, damage: Vec<DRect>) -> Self {
|
||||
Self { doc: photocraft_doc::DocId(0), revision: 0, key, damage }
|
||||
}
|
||||
|
||||
/// What a cache that showed raw preview `key` of this stroke at the revision before misses.
|
||||
pub(crate) fn since(&self, key: u64) -> Option<DRect> {
|
||||
let seen = if key == 0 { 0 } else { usize::try_from(key.checked_sub(self.key)?).ok()? };
|
||||
Some(self.damage.get(seen..)?.iter().fold(DRect::EMPTY, |a, r| a.union(r)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The live stroke on document `idx`, while it is current.
|
||||
fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> {
|
||||
let st = app.session.documents().get(idx)?;
|
||||
@@ -1133,6 +1156,15 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
/// rectangle did: the last edit's damage, or the live stroke's dabs since then. Cached keys have
|
||||
/// the colour display's key folded in (`^ display_key`); `now`'s is still raw.
|
||||
pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u64), display_key: u64, last_damage: Option<DRect>) -> Option<DRect> {
|
||||
// A brush stroke committed with the pixels its live preview already showed: only the steps
|
||||
// this cache hadn't shown yet (usually nothing), not the stroke's whole bounding box.
|
||||
if seen.0 + 1 == now.0
|
||||
&& let Some(st) = app.session.documents().get(idx)
|
||||
&& let Some(c) = app.committed_stroke.as_ref().filter(|c| c.doc == st.doc.id && c.revision == now.0)
|
||||
&& let Some(r) = c.since(seen.1 ^ display_key)
|
||||
{
|
||||
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
|
||||
}
|
||||
if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 {
|
||||
return last_damage;
|
||||
}
|
||||
@@ -4447,6 +4479,63 @@ fn finish_selection_drag(app: &mut PhotocraftApp, floating: bool, start: [f64; 2
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a Brush, Pencil or Eraser drag. The live stroke already rendered it: its pixels are
|
||||
/// offered to the commit (`Session::offer_live_stroke`), so release costs no second render, and
|
||||
/// the canvas then refreshes only what it hadn't shown yet (`CommittedStroke`).
|
||||
fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) {
|
||||
let live = app.live_stroke.take();
|
||||
let mut p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
|
||||
if let Some(seed) = live.as_ref().and_then(|l| l.stroke.seed()) {
|
||||
p["seed"] = json!(seed);
|
||||
}
|
||||
let Some(mut l) = live else {
|
||||
let _ = app.run(stroke_command(app, d.tool), p);
|
||||
return;
|
||||
};
|
||||
// Points the live stroke hasn't rendered yet (the release frame's last moves).
|
||||
let t0 = app.stylus.point_ms(0);
|
||||
let rest: Vec<_> = (l.fed..d.points.len())
|
||||
.filter_map(|i| {
|
||||
let q = d.points.get(i)?;
|
||||
let t = app.stylus.stroke.get(i).or(app.stylus.stroke.last()).copied().unwrap_or_default();
|
||||
let mut sp = photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32);
|
||||
(sp.tilt_x, sp.tilt_y, sp.rotation) = (t[0], t[1], t[2]);
|
||||
sp.time = app.stylus.point_ms(i) - t0;
|
||||
Some(sp)
|
||||
})
|
||||
.collect();
|
||||
if !rest.is_empty() {
|
||||
match l.stroke.push(&rest) {
|
||||
Ok(r) => l.damage.push(r),
|
||||
// The live stroke can't show these points: the commit renders the stroke itself.
|
||||
Err(_) => {
|
||||
let _ = app.run(stroke_command(app, d.tool), p);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let LiveStroke { stroke, doc, key, damage, .. } = l;
|
||||
if let EngineStroke::Brush(stroke) = stroke
|
||||
&& app.session.offer_live_stroke(*stroke).is_err()
|
||||
{
|
||||
app.session.clear_live_stroke();
|
||||
}
|
||||
let result = app.run(stroke_command(app, d.tool), p);
|
||||
app.session.clear_live_stroke();
|
||||
let Ok(r) = result else { return };
|
||||
let revision = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.revision);
|
||||
if r.get("fromLive").and_then(serde_json::Value::as_bool) == Some(true)
|
||||
&& let Some(revision) = revision
|
||||
{
|
||||
app.committed_stroke = Some(CommittedStroke { doc, revision, key, damage });
|
||||
} else {
|
||||
// Rendered again from scratch: the commit's damage rect refreshes the stroke.
|
||||
let shown = CommittedStroke { doc, revision: 0, key, damage };
|
||||
// Raw preview key 0 = the document itself (its colour display folded in).
|
||||
shown_as_document(app, doc, |k| shown.since(k).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]);
|
||||
if let Some(floating) = d.sel_move {
|
||||
@@ -4475,21 +4564,7 @@ pub(crate) 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.live),
|
||||
Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]),
|
||||
Tool::Type | Tool::VerticalType => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]),
|
||||
Tool::Brush | Tool::Pencil | Tool::Eraser => {
|
||||
let live = app.live_stroke.take();
|
||||
let mut p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
|
||||
if let Some(seed) = live.as_ref().and_then(|l| l.stroke.seed()) {
|
||||
p["seed"] = json!(seed);
|
||||
}
|
||||
// The canvas already shows the stroke: let the commit's damage rect refresh it rather
|
||||
// than recompositing the whole document.
|
||||
if app.run(stroke_command(app, d.tool), p).is_ok()
|
||||
&& let Some(l) = live
|
||||
{
|
||||
// Raw preview key 0 = the document itself (its colour display folded in).
|
||||
shown_as_document(app, l.doc, |k| l.since(k).is_some());
|
||||
}
|
||||
}
|
||||
Tool::Brush | Tool::Pencil | Tool::Eraser => commit_brush_stroke(app, &d),
|
||||
Tool::RectMarquee | Tool::EllipseMarquee => {
|
||||
// Exactly what the drag showed (`marquee_preview_px`); a click deselects.
|
||||
let Some([x0, y0, x1, y1]) = marquee_preview_px(&app.ui.tool_options, &d, app.point_zoom()) else {
|
||||
@@ -5518,7 +5593,9 @@ mod tests {
|
||||
assert_eq!(st.doc.layer(layer).unwrap().content, original.content);
|
||||
crate::channel_view::ensure(&mut app, &ctx, 0);
|
||||
let mut delta = ctx.tex_manager().write().take_delta();
|
||||
assert!(delta.set.get(&tex).unwrap()[0].pos.is_some(), "commit keeps its partial upload");
|
||||
// The commit takes the live stroke's pixels, which the texture already shows: at
|
||||
// most a partial upload, never the whole mask again.
|
||||
assert!(delta.set.get(&tex).is_none_or(|u| u.iter().all(|u| u.pos.is_some())), "commit keeps its partial upload");
|
||||
delta.clear();
|
||||
app.session.undo();
|
||||
assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original);
|
||||
|
||||
@@ -420,6 +420,9 @@ pub struct PhotocraftApp {
|
||||
pub(crate) tool_override: Option<state::Tool>,
|
||||
/// Brush/Eraser stroke being drawn, rendered by the engine (see `canvas::LiveStroke`).
|
||||
live_stroke: Option<canvas::LiveStroke>,
|
||||
/// A live stroke just committed with its own pixels: the canvas caches that showed it
|
||||
/// refresh only what it drew after the step they showed (`canvas::damage_since`).
|
||||
committed_stroke: Option<canvas::CommittedStroke>,
|
||||
/// Footprint trail of a retouching drag (see `stroke_trail`).
|
||||
trail: Option<stroke_trail::Trail>,
|
||||
/// The open menus' items, reused between frames (`menus::ItemCache`).
|
||||
@@ -629,6 +632,7 @@ impl PhotocraftApp {
|
||||
move_blocked: false,
|
||||
tool_override: None,
|
||||
live_stroke: None,
|
||||
committed_stroke: None,
|
||||
trail: None,
|
||||
menu_cache: None,
|
||||
move_preview: None,
|
||||
|
||||
@@ -299,3 +299,41 @@ fn a_stroke_ends_at_the_last_pressure_the_pen_drew_with() {
|
||||
assert!(pts.iter().all(|p| (p[2] - 0.4).abs() < 1e-3), "lift {lift:?}: no full or zero pressure dab at the end: {pts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_stroke_commits_the_live_pixels_and_replays_identically() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("tools.setBrush", json!({"brush": {"size": 14, "hardness": 0.2}, "smoothing": {"amount": 0.3}})).unwrap();
|
||||
move_to(&mut h, 20.0, 50.0);
|
||||
button(&mut h, 20.0, 50.0, true);
|
||||
for (x, y) in [(40.0, 30.0), (70.0, 60.0), (110.0, 40.0), (150.0, 70.0)] {
|
||||
move_to(&mut h, x, y);
|
||||
}
|
||||
button(&mut h, 150.0, 70.0, false);
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
let st = app.session.active().unwrap();
|
||||
let committed = app.committed_stroke.as_ref().expect("the commit took the live stroke's pixels");
|
||||
assert_eq!((committed.doc, committed.revision), (st.doc.id, st.revision));
|
||||
// Replaying the journaled command from scratch gives the same pixels.
|
||||
let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == "paint.stroke").cloned().unwrap();
|
||||
let mut fresh = photocraft_engine::Session::new();
|
||||
fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap();
|
||||
fresh.execute("tools.setBrush", json!({"brush": {"size": 14, "hardness": 0.2}, "smoothing": {"amount": 0.3}})).unwrap();
|
||||
fresh.execute("paint.stroke", p).unwrap();
|
||||
let a = photocraft_compose::render(&st.doc, st.doc.bounds());
|
||||
let b = photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds());
|
||||
assert_eq!(a.px.len(), b.px.len());
|
||||
assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "live commit and replay differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() {
|
||||
use crate::canvas::CommittedStroke;
|
||||
let r = |x: i32| photocraft_geom::Rect::new(x, 0, x + 10, 10);
|
||||
let c = CommittedStroke::for_test(100, vec![r(0), r(20), r(40)]);
|
||||
assert_eq!(c.since(103), Some(photocraft_geom::Rect::EMPTY), "showed the last step: nothing");
|
||||
assert_eq!(c.since(102), Some(r(40)), "one step behind: that step");
|
||||
assert_eq!(c.since(0), Some(r(0).union(&r(20)).union(&r(40))), "never showed the stroke: all of it");
|
||||
assert_eq!(c.since(99), None, "not a key of this stroke");
|
||||
}
|
||||
+4
-4
@@ -6,9 +6,9 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 16 not run, 6 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 21 | 16 | 5 | |
|
||||
| [Tools](#tools) | 21 | 17 | 4 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 43 of 151 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) |
|
||||
@@ -54,6 +54,7 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
|
||||
| P28 | Content-Aware Scale 24 MP (to 90 % width) | ≤ 3000 ms p95 | 954192 | 954192 | 954192 | 2.1 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P29 | Select Subject on 24 MP, repeated | ≤ 100 ms p95 | 559 | 693 | 693 | 1.4 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P31 | Camera Raw exposure change (proxy) | ≤ 10 ms | – | – | – | – | – | not run | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P32 | Brush release on a long stroke | ≤ 16 ms (no hitch at mouse-up) | – | – | – | – | – | not run | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P33 | Brush dab on A4 300 ppi CMYK (dab to screen) | ≤ 6 ms p95 | 34.7 | 235 | 235 | 775 MB | 87 MB | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P36 | Camera Raw exposure change with all SDR scopes (CPU) | ≤ 10 ms p95 | – | – | – | – | – | not run | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P40 | Brush dab 60 px on 36 MP | track only | 1.45 | 3.19 | 3.19 | 1.3 GB | – | tracked | |
|
||||
@@ -82,7 +83,6 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
|
||||
| P20 | Open a 2 GB PSD / 4–5 GB PSB | opens with progress, editable, never blank | opens run in the background with progress (#210), but there are no generated large-file fixtures yet (#216) | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P23 | Warp / Puppet / Perspective preview at 4K | ≤ 16 ms per frame, commit in the background | the previews run inside the UI tools; no headless frame harness, no background commit | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P30 | Liquify 800 px brush on 3500×2200 at full resolution | ≤ 1 ms per update | the Liquify proxy is capped at 1600 px and the stroke path lives in the UI tool | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P32 | Brush release on a long stroke | no hitch at mouse-up | the live renderer and the release re-render are UI-side; needs a frame harness | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P34 | Brush preview memory, small dab on an 8K viewport | ≤ 2 MiB | the live brush preview buffers are UI-side; no headless counter yet | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P35 | Marquee drag on 24 MP with Alt/Shift | ≤ 17 ms p95 | Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
|
||||
@@ -136,7 +136,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers; background job) + Tool::ContentAwareMove (J group: lasso, then drag). Missing: Transform On Drop, a live preview of the result (the drag shows the outline only) |
|
||||
| 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-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp |
|
||||
| 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) | Clone Source overlay drawn with opacity, inversion, circular clipping and auto-hide; W/H link, custom-tip clipping and overlay blend modes remain missing |
|
||||
| TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair |
|
||||
| TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there |
|
||||
|
||||
+5
-2
@@ -374,8 +374,11 @@ enforce = false # not in the 2026-10-05 baseline yet; contributor M4 run 2026-1
|
||||
id = "P32"
|
||||
title = "Brush release on a long stroke"
|
||||
issue = 211
|
||||
target = "no hitch at mouse-up"
|
||||
not_measurable = "the live renderer and the release re-render are UI-side; needs a frame harness"
|
||||
target = "≤ 16 ms (no hitch at mouse-up)"
|
||||
bench = "perf_scenarios"
|
||||
row = "brush release: 600-point stroke, 200 px soft, 24 MP"
|
||||
budget_ms = 16
|
||||
enforce = false # measurable since the release commits the live stroke's pixels; awaiting a baseline run
|
||||
|
||||
[[scenario]]
|
||||
id = "P33"
|
||||
|
||||
@@ -50,9 +50,9 @@ note = "fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opa
|
||||
[[item]]
|
||||
id = "TOOL-213-7"
|
||||
target = "Live stroke preview for every brush-based retouch tool"
|
||||
status = "missing"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "Live stroke only for Brush and Eraser (canvas.rs:1684)"
|
||||
note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-8"
|
||||
|
||||
Reference in new issue
Block a user