Image Trace: a background trace applies only to the pixels it traced

A trace made away from its command (the background trace, #3098) counted
as current when the object, its image record and the settings were
unchanged, without looking at the image data. When other pixels came to
sit under the same image key (Rasterize after an undo reuses the key and
the id, Update Link, a replaced blob), the finished trace applied its old
paths to the new image.

The trace's inputs now include the encoded bytes it decodes, compared by
allocation first and then by content. TraceRun::current and the hand-off
in imageTrace.make and imageTrace.makeAndExpand both use them, so a stale
trace is dropped and the command traces the pixels there now. Equal bytes
in another allocation still count as the same image.
This commit is contained in:
dotCooCoo
2026-11-13 07:18:53 -08:00
parent e8c7421a3d
commit 08e1b7af80
2 changed files with 114 additions and 13 deletions
+97
View File
@@ -3788,6 +3788,103 @@ fn a_trace_run_needs_the_library_colours_it_traced_with() {
assert!(!run.current(&s), "{name} changed");
}
/// `image_doc`'s image with its disc 20 px to the left: the same size, other pixels.
fn moved_disc_png() -> Vec<u8> {
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let (dx, dy) = (x as f64 + 0.5 - 30.0, y as f64 + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
})
.encode_png()
}
/// Puts `bytes` under the image key `key`, or takes the key's image data away (`None`).
fn set_image_data(s: &mut Session, key: &str, bytes: Option<Vec<u8>>) {
s.edit("image data", |d, _| {
match bytes {
Some(b) => d.images.insert(key.into(), ImageBlob::new("image/png", b)),
None => d.images.remove(key),
};
Ok(())
})
.unwrap();
}
/// The middle of the first traced path of the Image Trace object the reply `r` names, in x.
fn traced_center_x(s: &Session, r: &Value) -> f64 {
outline(&traced_paths(&node(s, NodeId(r["id"].as_u64().unwrap())))[0]).0.bounds().unwrap().center().x
}
/// A trace made away from its command no longer applies once the pixels under its image's key
/// change, though the object, its image record and the settings stay as they were; both commands
/// then trace the pixels there now. The same bytes in another allocation still apply.
#[test]
fn a_trace_run_needs_the_pixels_it_traced() {
let p = json!({"params": {"ignoreWhite": true}});
for command in ["imageTrace.make", "imageTrace.makeAndExpand"] {
let mut s = session();
image_doc(&mut s);
let run = cmd::TraceRun::prepare(&s, command, &p).unwrap();
let traced = run.run(&|_, _| true).unwrap();
// The same bytes, allocated anew: still current.
let same = s.doc().unwrap().doc.images["img1"].bytes.to_vec();
set_image_data(&mut s, "img1", Some(same));
assert!(run.current(&s), "{command}: equal bytes");
// Other pixels under the same key: no longer current, and the hand-off is dropped.
set_image_data(&mut s, "img1", Some(moved_disc_png()));
assert!(!run.current(&s), "{command}: other pixels");
traced.hand_in(&mut s);
let r = s.execute(command, run.params()).unwrap();
assert!(s.trace_hand_off.is_none(), "{command}: dropped");
// The disc is now at image x 30, which the ×2 placement at 50 puts at 110.
let x = traced_center_x(&s, &r);
assert!((x - 110.0).abs() < 1.5, "{command}: traced the pixels there now, at {x}");
}
}
/// Image data that is missing or empty: a run made before it went no longer applies, the run's
/// command gives the error it gives without a run, and nothing panics.
#[test]
fn a_trace_run_of_missing_or_empty_image_data() {
let p = json!({"params": {"ignoreWhite": true}});
let mut s = session();
image_doc(&mut s);
let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap();
let traced = run.run(&|_, _| true).unwrap();
set_image_data(&mut s, "img1", None);
assert!(!run.current(&s), "missing");
traced.hand_in(&mut s);
let e = s.execute(run.command(), run.params()).unwrap_err().to_string();
assert!(e.contains("image data `img1` is missing"), "{e}");
set_image_data(&mut s, "img1", Some(vec![]));
assert!(!run.current(&s), "empty");
let empty = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap();
assert!(empty.current(&s), "two empty blobs are equal");
assert!(matches!(empty.run(&|_, _| true), Err(EngineError::Other(_))));
assert!(matches!(s.execute(run.command(), run.params()), Err(EngineError::Other(_))));
}
/// Tracing an Image Trace object again follows the same rule: once the pixels of the image it
/// holds change, a run made before no longer applies, and the command traces the pixels there now.
#[test]
fn a_trace_run_of_an_image_trace_object_needs_its_pixels() {
let p = json!({"params": {"ignoreWhite": true}});
let mut s = session();
image_doc(&mut s);
let g = s.execute("imageTrace.make", &p).unwrap()["id"].as_u64().unwrap();
let again = json!({"id": g, "params": {"ignoreWhite": true}});
let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &again).unwrap();
assert_eq!(run.target(), NodeId(g));
let traced = run.run(&|_, _| true).unwrap();
assert!(run.current(&s));
set_image_data(&mut s, "img1", Some(moved_disc_png()));
assert!(!run.current(&s));
traced.hand_in(&mut s);
let r = s.execute(run.command(), run.params()).unwrap();
assert!(s.trace_hand_off.is_none(), "dropped");
let x = traced_center_x(&s, &r);
assert!((x - 110.0).abs() < 1.5, "traced the pixels there now, at {x}");
}
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
/// circle as its traced path; without the option it is a plain path.
#[test]