Releasing a brush stroke no longer freezes: the commit takes the pixels the live stroke already drew (P32: 136 ms → 0.8 ms)

Brush phase 4. On release the canvas dropped its live stroke and ran `paint.stroke` over every
point again (the whole stroke re-rendered), then re-composited the stroke's bounding box, although
the canvas already showed exactly those pixels. Long strokes froze for 100–250 ms at mouse-up
(#2068).

- `Session::offer_live_stroke` hands the finished live stroke to the next command. A
  `paint.stroke` / `paint.pencil` whose command, document revision, params, seed and points match
  it exactly takes its pixels instead of rendering (result `"fromLive": true`). Anything else
  ignores the offer and renders as before. It still runs through the normal dispatch
  (authorization, journal, one history step), and a replay renders identical pixels.
- The canvas feeds the release frame's last points to the live stroke first, then remembers the
  stroke's per-step damage for one revision (`CommittedStroke`): each canvas cache re-composites
  only the steps it hadn't shown, usually nothing, instead of the bounding box.
- P32 (brush release on a long stroke) becomes measurable: perf_scenarios times a 600-point
  200 px soft stroke on 24 MP. 136 ms → 0.83 ms on the GPU canvas, 257 ms → 0.71 ms on the CPU
  canvas (RTX 5090, release). Budget 16 ms, not enforced until a baseline run.
- Scorecard: TOOL-213-10 (⌃⌥-drag resize with readout) is done; TOOL-213-7 (live strokes for every
  brush-based retouch tool) is partial (Spot Healing, Mixer Brush, Background Eraser and Pattern
  Stamp still aren't live).
This commit is contained in:
Tanish Manku 2026-10-09 15:07:59 -07:00
parent 917fbcb006
commit 541d4f4acd
10 changed files with 368 additions and 31 deletions

View File

@ -246,12 +246,21 @@ fn erase_locked(brush: &mut BrushSettings, lock: bool, bg: [f32; 4]) {
}
/// 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)?;
// 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 = 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;
@ -285,7 +294,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
@ -328,7 +341,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
@ -353,6 +366,44 @@ pub struct LiveStroke {
params: Value,
/// Where the doc shows the stroke's end as finishing it would draw it (see `push`).
tail: Rect,
/// 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 {
@ -374,7 +425,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);
@ -401,11 +454,20 @@ impl LiveStroke {
layer,
params: p.clone(),
tail: Rect::EMPTY,
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);
@ -425,6 +487,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);
self.renderer.push(pts);
if let Some(symmetry) = &self.symmetry {
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
@ -483,7 +546,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 {

View File

@ -639,3 +639,78 @@ fn out_of_range_scatter_is_rejected_and_the_maximum_still_paints() {
s.execute("tools.setBrush", json!({ "brush": scatter(2.0) })).unwrap();
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [17, 14, 23, 23]}));
}
/// 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());
}

View File

@ -324,6 +324,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
@ -427,6 +430,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)

View File

@ -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;
@ -842,4 +862,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)?)
});
}
}

View File

@ -325,6 +325,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)?;
@ -963,6 +986,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;
}
@ -3948,6 +3980,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(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(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(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 {
@ -3976,21 +4065,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(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 => {
let (a, b) = marquee_corners(&app.ui.tool_options, &d, [end[0], end[1]]);
let [x0, y0, x1, y1] = marquee_px(a, b);

View File

@ -359,6 +359,9 @@ pub struct PhotocraftApp {
drag: Option<canvas::Drag>,
/// 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>,
/// Move tool drag shown live (`move_ui`).
@ -548,6 +551,7 @@ impl PhotocraftApp {
checker: None,
drag: None,
live_stroke: None,
committed_stroke: None,
trail: None,
move_preview: None,
blend_preview: None,

View File

@ -163,3 +163,41 @@ fn moves_in_one_frame_take_the_pressures_the_pen_reported_between_them() {
let tail: Vec<f64> = pts.iter().filter(|p| p[0] >= 29.0).map(|p| (p[2] * 100.0).round() / 100.0).collect();
assert_eq!(tail.get(..3), Some(&[0.4, 0.6, 0.8][..]), "each move keeps its own pressure: {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");
}

View File

@ -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) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 17 | 14 | 8 | |
| [Tools](#tools) | 18 | 15 | 6 | |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 48 of 141 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 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,10 +136,10 @@ 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 | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Only [ ] resize (shortcuts.rs:270) |
| 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: ⌃⌥-drag (and ⌥-right-drag) sets diameter horizontally and hardness vertically with the circle and readout beside the pointer, never painting (#271; tests ctrl_alt_drag_resizes_every_brush_tool_without_painting_or_history, canvas_drag_shows_the_circle_and_readout) |
| TOOL-213-11 | Editable pressure curve, global and per control | missing | [#213](https://github.com/storytold/photocraft/issues/213) | No pressure curve in engine, paint or ui-egui |
| TOOL-213-12 | Import an image file as a brush tip | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Only brush.defineFromSelection and brush.presets.importAbr |
| TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs |

View File

@ -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"

View File

@ -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"
@ -71,9 +71,9 @@ note = "eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the fo
[[item]]
id = "TOOL-213-10"
target = "Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout"
status = "missing"
status = "done"
issue = 213
note = "Only [ ] resize (shortcuts.rs:270)"
note = "brush_resize.rs: ⌃⌥-drag (and ⌥-right-drag) sets diameter horizontally and hardness vertically with the circle and readout beside the pointer, never painting (#271; tests ctrl_alt_drag_resizes_every_brush_tool_without_painting_or_history, canvas_drag_shows_the_circle_and_readout)"
[[item]]
id = "TOOL-213-11"