Merge pull request #4605 from VoiceLessQ/fix/3098-trace-off-the-ui-thread

Fix #3098: Image Trace traces off the UI thread, with progress and Cancel
This commit is contained in:
BFlat
2026-10-29 22:58:45 +07:00
committed by GitHub
23 changed files with 806 additions and 35 deletions
+3
View File
@@ -596,6 +596,9 @@ pub use flatten::FlattenerPreset;
/// A saved Image Trace preset ([`crate::Prefs::trace_presets`]), and a change to the saved presets.
pub use buildcmds::{TracePreset, TracePresetChange};
/// An Image Trace made off the UI thread, and what it traced (#3098).
pub use buildcmds::{TraceRun, Traced};
/// The saved Image Trace presets' revision and latest changes ([`crate::Session`] keeps them).
pub(crate) use buildcmds::TracePresetChanges;
+1 -1
View File
@@ -41,7 +41,7 @@ pub use live_paint::live_paint_gaps;
use live_paint::{lp_expand, lp_fill, lp_gap_options, lp_info, lp_make, lp_merge, lp_release, lp_stroke_edge};
use shape_builder::{sb_merge, sb_regions};
pub(crate) use trace::TracePresetChanges;
pub use trace::{TracePreset, TracePresetChange};
pub use trace::{TracePreset, TracePresetChange, TraceRun, Traced};
use trace::{trace_expand, trace_make, trace_presets_delete, trace_presets_save, trace_release, trace_set_view};
/// Name of an Image Trace group.
+147 -16
View File
@@ -1,4 +1,5 @@
use vectorcraft_color::Swatch;
use vectorcraft_doc::ImageObject;
use super::{
Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point,
@@ -369,8 +370,31 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
}
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
/// What a trace depends on: the object traced (an image, or an Image Trace object traced again),
/// its image, and the settings it is traced with. The tracer makes the same paths of the same
/// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own.
#[derive(Clone, Debug, PartialEq)]
struct TraceInputs {
target: NodeId,
image: Box<ImageObject>,
/// The settings traced with, as JSON, the library's colours in.
params: Value,
}
/// What `imageTrace.make` and `imageTrace.makeAndExpand` take from the session before they trace.
struct Prepared {
inputs: TraceInputs,
trace: tr::TraceParams,
view: Option<TraceView>,
/// The settings asked for, which the Image Trace object keeps.
params_json: Value,
preset_label: String,
library: Vec<([u8; 3], Paint)>,
bytes: Arc<Vec<u8>>,
}
/// What command `c` with params `p` traces, and how, from the session: everything but the trace.
fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
let mut params = trace_params(s, c, p)?;
let view = view_param(c, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
@@ -384,15 +408,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
None if is(tr::preset(named)) || is(tr::enhanced_preset(named)) => named.to_string(),
None => "Custom".to_string(),
};
// A saved preset's settings join the journal entry, so a replay traces as this run did after
// the preset is changed, renamed or deleted.
if let Some(q) = s.trace_preset(named).filter(|_| tr::preset(named).is_none()) {
let recorded = serde_json::to_value(&q.params).map_err(|e| EngineError::Other(e.to_string()))?;
s.note_journal("savedPreset", recorded);
}
let roots = root_ids(s, p)?;
// The image (inside a trace group, or selected directly).
let (target, img) = {
let (target, image) = {
let d = &s.doc()?.doc;
roots
.iter()
@@ -409,15 +427,128 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
})
.ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))?
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
// An image over the tracer's size is traced scaled down (the paths are scaled back below).
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?;
let bytes = Arc::clone(&blob.bytes);
let library = trace_library(c, s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at);
let traced_with = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
let inputs = TraceInputs { target, image, params: traced_with };
Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library, bytes })
}
/// What a trace made of an image: the size in pixels it was decoded at, and the paths.
pub struct Traced {
inputs: TraceInputs,
raster: (u32, u32),
res: tr::TraceResult,
}
impl Traced {
/// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]), which
/// applies it as one undo step instead of tracing again. Any other trace drops it.
pub fn hand_in(self, s: &mut Session) {
s.trace_hand_off = Some(self);
}
}
/// Decode the image `bytes` and trace it with `params`, telling `steps` how far it has got
/// ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`.
fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, bytes: &[u8], steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
// An image over the tracer's size is traced scaled down (the paths are scaled back).
let raster = tr::Raster::decode_to_fit(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
if !steps(0, 0) {
return Err(EngineError::Cancelled);
}
let res = tr::trace_watched(&raster, params, MAX_TRACE_ANCHORS, steps).map_err(|e| match e {
tr::TraceError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
})?;
Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res })
}
/// An Image Trace made away from its command, off the UI thread (#3098). [`TraceRun::prepare`]
/// takes what `imageTrace.make` or `imageTrace.makeAndExpand` traces from the session, checked as
/// the command checks it; [`TraceRun::run`] decodes and traces the image without the session, on
/// any thread; [`Traced::hand_in`] gives the result to the command.
pub struct TraceRun {
command: &'static str,
params: Value,
prepared: Prepared,
}
impl TraceRun {
/// The trace `command` (`imageTrace.make` or `imageTrace.makeAndExpand`) with params `p` makes
/// in the active document.
pub fn prepare(s: &Session, command: &str, p: &Value) -> Result<TraceRun> {
let command = match command {
"imageTrace.make" => "imageTrace.make",
"imageTrace.makeAndExpand" => "imageTrace.makeAndExpand",
other => return Err(EngineError::Other(format!("`{other}` doesn't trace an image"))),
};
let prepared = prepare(s, command, p)?;
// The object traced is named, so the command applies the trace to it whatever is
// selected by then.
let mut params = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = params.as_object_mut() {
o.remove("ids");
o.insert("id".into(), json!(prepared.inputs.target.0));
}
Ok(TraceRun { command, params, prepared })
}
/// `imageTrace.make` or `imageTrace.makeAndExpand`.
pub fn command(&self) -> &'static str {
self.command
}
/// The command's params that apply this trace: those it was prepared with, the object traced
/// in `id`.
pub fn params(&self) -> &Value {
&self.params
}
/// The object it traces: an image, or an Image Trace object traced again.
pub fn target(&self) -> NodeId {
self.prepared.inputs.target
}
/// The size in pixels of the image it traces.
pub fn image_size(&self) -> (u32, u32) {
(self.prepared.inputs.image.width, self.prepared.inputs.image.height)
}
/// Does it still apply: would the command trace the same object, image and settings now?
pub fn current(&self, s: &Session) -> bool {
prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs)
}
/// Decode and trace the image, telling `steps` how far it has got → what it traced, or why it
/// failed or stopped ([`EngineError::Cancelled`] once `steps` answers `false`). It touches no
/// document, so it can run on any thread.
pub fn run(&self, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
trace_now(&self.prepared.inputs, &self.prepared.trace, &self.prepared.bytes, steps)
}
}
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?;
// A saved preset's settings join the journal entry, so a replay traces as this run did after
// the preset is changed, renamed or deleted.
let named = str_param(p, "preset").unwrap_or("Default");
if let Some(q) = s.trace_preset(named).filter(|_| tr::preset(named).is_none()) {
let recorded = serde_json::to_value(&q.params).map_err(|e| EngineError::Other(e.to_string()))?;
s.note_journal("savedPreset", recorded);
}
// A trace handed in for these very inputs ([`TraceRun`]), else one made now.
let Traced { raster, res, .. } = match s.trace_hand_off.take() {
Some(traced) if traced.inputs == inputs => traced,
_ => trace_now(&inputs, &trace, &bytes, &|_, _| true)?,
};
let TraceInputs { target, image: img, params: _ } = inputs;
let warnings = trace_warnings((img.width, img.height), raster, res.worked_at);
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
let sx = img.width.max(1) as f64 / raster.0.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.1.max(1) as f64;
let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
// Document units per raster pixel (stroke widths).
let unit = xf.determinant().abs().sqrt();
+3
View File
@@ -161,6 +161,9 @@ pub struct Session {
/// While [`Session::place_queue_read`] runs `file.place.queue`: the files read for it, which
/// the command loads the place cursor with instead of reading them; `None` otherwise.
pub(crate) queue_hand_off: Option<cmd::place::QueueRead>,
/// What a [`cmd::TraceRun`] traced, until the next `imageTrace.make` or
/// `imageTrace.makeAndExpand` takes it ([`cmd::Traced::hand_in`]).
pub(crate) trace_hand_off: Option<cmd::Traced>,
/// Whether a click or drag of the place cursor asks the frontend to place its file
/// ([`UiRequest::Place`]) instead of running `file.place`: the desktop app sets it to read the
/// file off the UI thread. False: the file is placed at once.
+1
View File
@@ -70,6 +70,7 @@ impl Session {
batch_steps: None,
place_hand_off: None,
queue_hand_off: None,
trace_hand_off: None,
place_cursor_requests: false,
package_hand_off: None,
screens_hand_off: None,
+43
View File
@@ -2725,6 +2725,49 @@ fn image_trace_make_places_paths_over_the_image() {
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
}
/// #3098: a trace made away from the command (off the UI thread) and handed in makes the command's
/// own edit, as one undo step; Cancel stops it; once its image has changed it no longer applies.
#[test]
fn a_trace_run_hands_its_trace_to_the_command() {
let p = json!({"params": {"ignoreWhite": true}});
let mut direct = session();
image_doc(&mut direct);
let want = direct.execute("imageTrace.make", &p).unwrap();
let mut s = session();
let img = image_doc(&mut s);
let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap();
assert_eq!((run.command(), run.params()["id"].as_u64(), run.image_size()), ("imageTrace.make", Some(img.0), (100, 100)));
let total = std::sync::atomic::AtomicU64::new(0);
let traced = run
.run(&|_, t| {
total.fetch_max(t, std::sync::atomic::Ordering::Relaxed);
true
})
.unwrap();
assert!(total.into_inner() > 0, "the layers are counted");
assert!(run.current(&s));
traced.hand_in(&mut s);
let got = s.execute(run.command(), run.params()).unwrap();
assert!(s.trace_hand_off.is_none(), "taken");
assert_eq!(got, want);
let g = NodeId(got["id"].as_u64().unwrap());
assert_eq!(node(&s, g), node(&direct, g));
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)), "one undo step");
assert!(matches!(run.run(&|_, _| false), Err(EngineError::Cancelled)));
assert!(matches!(cmd::TraceRun::prepare(&s, "imageTrace.release", &p), Err(EngineError::Other(_))));
// The image moved meanwhile: the run no longer applies, and the command traces it anew.
let traced = run.run(&|_, _| true).unwrap();
s.execute("object.move", &json!({"ids": [img.0], "dx": 10, "dy": 0})).unwrap();
assert!(!run.current(&s));
traced.hand_in(&mut s);
let moved = s.execute(run.command(), run.params()).unwrap();
assert!(s.trace_hand_off.is_none(), "dropped");
let bb = outline(&traced_paths(&node(&s, NodeId(moved["id"].as_u64().unwrap())))[0]).0.bounds().unwrap();
assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}");
}
/// 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]
+1 -1
View File
@@ -79,7 +79,7 @@ use layers::parallel_map_at_most;
#[cfg(test)]
pub(crate) use layers::shift_probe;
pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets};
pub use pipeline::{TraceResult, TracedPath, trace, trace_within};
pub use pipeline::{TraceResult, TracedPath, trace, trace_watched, trace_within};
#[cfg(test)]
use raster::fit_size;
pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError};
+44 -3
View File
@@ -1,3 +1,5 @@
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use super::layers::{Layers, parallel_map_at_most};
use super::params::{Method, Mode, TraceParams};
use super::raster::{Raster, TraceError};
@@ -68,11 +70,24 @@ pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The
/// colour layers are traced in parallel where threads are available.
pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
trace_watched(img, params, max_anchors, &|_, _| true)
}
/// [`trace_within`], telling `steps` how far it has got: `steps(done, total)` after each stage
/// that prepares the colour layers (`total` 0 while they aren't known yet) and after each colour
/// layer, from whichever thread traced it. Once `steps` answers `false` the trace stops at the
/// next stage or layer with [`TraceError::Cancelled`].
pub fn trace_watched(
img: &Raster,
params: &TraceParams,
max_anchors: usize,
steps: &(dyn Fn(u64, u64) -> bool + Sync),
) -> Result<TraceResult, TraceError> {
let (w, h) = (img.width as usize, img.height as usize);
if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) {
return Ok(TraceResult::default());
}
let mut out = trace_paths(img, params, max_anchors)?;
let mut out = trace_paths(img, params, max_anchors, steps)?;
if params.auto_grouping {
// Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can.
let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes;
@@ -81,9 +96,12 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
Ok(out)
}
fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<TraceResult, TraceError> {
let (w, h) = (img.width as usize, img.height as usize);
// After each stage before the colour layers, whose number isn't known yet.
let go_on = || if steps(0, 0) { Ok(()) } else { Err(TraceError::Cancelled) };
if params.mode == Mode::Logo {
go_on()?;
return logo::trace_logo(img, params, max_anchors);
}
// The colour Ignore Color leaves out, if not white.
@@ -94,6 +112,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
let img = smoothed.as_ref().unwrap_or(img);
go_on()?;
// Gradients are found first and kept out of the palette: no colour is spent on bands of them.
// Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray.
let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| {
@@ -106,6 +125,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
});
let traced_as = grays.as_ref().unwrap_or(img);
let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty());
go_on()?;
let without = found.as_ref().map(|(of, _)| {
let mut rest = img.clone();
for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) {
@@ -116,6 +136,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
rest
});
let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params);
go_on()?;
// The semi-transparent edge of a cut-out image traces as the color beside it.
if matches!(params.mode, Mode::Color | Mode::Grayscale) {
quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale);
@@ -138,6 +159,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
// Noise clears specks, not the regions of a rare colour Automatic kept on purpose.
let keep: Vec<bool> = q.palette.iter().map(|c| rare.contains(c)).collect();
quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep);
go_on()?;
// Pixel count per palette entry, and the box its pixels lie in.
let mut counts = vec![0usize; q.palette.len()];
let mut boxes: Vec<Option<(usize, usize, usize, usize)>> = vec![None; q.palette.len()];
@@ -194,7 +216,26 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
// freed before the trace starts.)
let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1);
let at_a_time = ((512 << 20) / (largest * 10)).max(1);
for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
let total = layers.len() as u64;
if !steps(0, total) {
return Err(TraceError::Cancelled);
}
// Once stopped, the layers not started yet are skipped.
let (done, stopped) = (AtomicU64::new(0), AtomicBool::new(false));
let traced = parallel_map_at_most(at_a_time, &layers, |&(r, ci)| {
if stopped.load(Ordering::Relaxed) {
return None;
}
let paths = job.layer(r, ci);
if !steps(done.fetch_add(1, Ordering::Relaxed) + 1, total) {
stopped.store(true, Ordering::Relaxed);
}
paths
});
if stopped.load(Ordering::Relaxed) {
return Err(TraceError::Cancelled);
}
for (paths, &(_, ci)) in traced.into_iter().zip(&layers) {
let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?;
// A layer with no paths adds no color to the palette.
if paths.is_empty() {
+3
View File
@@ -15,6 +15,9 @@ pub enum TraceError {
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
)]
TooComplex { max: usize },
/// The caller of [`crate::trace_watched`] stopped the trace.
#[error("the trace was cancelled")]
Cancelled,
}
/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are
+22
View File
@@ -586,6 +586,28 @@ fn a_trace_past_its_anchor_budget_is_refused() {
assert_eq!(trace_within(&img, &p, anchors).unwrap(), full);
}
/// #3098: a watched trace says how far it has got, layer by layer, and stops when told to.
#[test]
fn a_watched_trace_counts_its_layers_and_stops_when_told() {
let img = stripes(120, 8);
let p = TraceParams { mode: Mode::Color, colors: 16, noise: 1, ..TraceParams::default() };
let seen = std::sync::Mutex::new(vec![]);
let watched = trace_watched(&img, &p, usize::MAX, &|done, total| {
seen.lock().unwrap().push((done, total));
true
});
assert_eq!(watched.unwrap(), trace(&img, &p), "watching changes nothing");
let seen = seen.into_inner().unwrap();
let total = seen.last().unwrap().1;
assert!(total > 1, "{seen:?}");
let mut layers: Vec<u64> = seen.iter().filter(|s| s.1 == total && s.0 > 0).map(|s| s.0).collect();
layers.sort_unstable();
assert_eq!(layers, (1..=total).collect::<Vec<_>>(), "each layer once: {seen:?}");
// Stopped before the layers, and after the first.
assert!(matches!(trace_watched(&img, &p, usize::MAX, &|_, _| false), Err(TraceError::Cancelled)));
assert!(matches!(trace_watched(&img, &p, usize::MAX, &|done, _| done == 0), Err(TraceError::Cancelled)));
}
/// Layers traced in parallel come out in the same order every time: bottom (largest) first.
#[test]
fn parallel_layers_keep_their_order() {
+4 -1
View File
@@ -12,7 +12,7 @@ use super::{
Arc, AutomationRoots, ControlRequest, Ordering, Receiver, Session, UiState, Value, VectorcraftApp, View, ViewInfo, background, canvas, chrome,
clipboard_probe, dialogs, export_progress, file_access, fileio, font_menu, graphics, i18n, io, json, menus, native_menu, opening, palette,
panel_docking, panels, picks, place, placing, plugin_runs, prefs_dialog, recovery, screen_color, scrub, shortcut_editor, shortcuts, theme,
titlebar, toolbar, tools_float, unsaved, updates_ui, widgets,
titlebar, toolbar, tools_float, trace_runs, unsaved, updates_ui, widgets,
};
mod clipboard;
@@ -121,6 +121,7 @@ impl VectorcraftApp {
opening: Default::default(),
plugin_runs: Default::default(),
placing: Default::default(),
trace_runs: Default::default(),
recovery: Default::default(),
host_modifiers: Default::default(),
forwarded: Vec::new(),
@@ -289,6 +290,7 @@ impl VectorcraftApp {
screen_color::poll(self, ctx);
opening::poll(self);
plugin_runs::poll(self);
trace_runs::poll(self);
placing::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
@@ -487,6 +489,7 @@ impl VectorcraftApp {
updates_ui::show(self, &ctx);
export_progress::show(self, &ctx);
plugin_runs::show(self, &ctx);
trace_runs::show(self, &ctx);
palette::show(self, &ctx);
if self.custom_titlebar {
titlebar::resize_zones(ui);
+3
View File
@@ -65,6 +65,7 @@ pub mod titlebar;
pub mod toolbar;
mod tools_float;
pub mod touch;
pub mod trace_runs;
mod ui_fonts;
pub mod unsaved;
pub mod updates_ui;
@@ -215,6 +216,8 @@ pub struct VectorcraftApp {
pub plugin_runs: plugin_runs::PluginRuns,
/// Files being read off the UI thread for Place ([`placing`]).
pub placing: placing::Placing,
/// Image Traces running off the UI thread ([`trace_runs`]).
pub trace_runs: trace_runs::TraceRuns,
/// Data Recovery's timer and startup question ([`recovery`]).
pub recovery: recovery::Timer,
/// Modifiers the keyboard holds (from the host's input), given back after synthetic input.
+7
View File
@@ -149,6 +149,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
&& p.as_object().is_some_and(|o| !o.contains_key("to"))
&& app.session.active().is_some_and(|st| st.selection.anchors.is_empty());
let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p };
// Image Trace traces off the UI thread, with its progress (#3098).
if matches!(id, "imageTrace.make" | "imageTrace.makeAndExpand") {
if let Err(e) = crate::trace_runs::start(app, id, p, false) {
app.status(e);
}
return;
}
if let Err(e) = app.run(id, p) {
app.status(e);
} else if matches!(id, "object.pattern.make" | "object.pattern.edit") {
+2
View File
@@ -827,6 +827,8 @@ fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
"ui.installPlugin" => io::install_plugin(app, p),
"plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)),
"plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)),
"imageTrace.cancelRuns" => Ok(crate::trace_runs::cancel_all(app)),
"imageTrace.hideRuns" => Ok(crate::trace_runs::hide_all(app)),
"ui.checkScript" => crate::dialogs::script_check::command(app, p),
"ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app),
"ui.perspectivePresetsDialog" => {
+2
View File
@@ -190,6 +190,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()),
"plugin.cancelRuns" => crate::plugin_runs::any_running(app),
"plugin.hideRuns" => crate::plugin_runs::any_shown(app),
"imageTrace.cancelRuns" => crate::trace_runs::any_running(app),
"imageTrace.hideRuns" => crate::trace_runs::any_shown(app),
"file.place"
| "file.export.svg"
| "file.export.png"
+12
View File
@@ -1133,6 +1133,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}",
),
(
"imageTrace.cancelRuns",
"Cancel Image Trace",
"",
"{} stop every Image Trace running in the background (its progress window's Cancel): it stops at its next colour and changes nothing → {cancelled: n}",
),
(
"imageTrace.hideRuns",
"Continue in Background",
"",
"{} close the progress window of the Image Traces running in the background; they go on, shown in the status bar → {hidden: n}",
),
(
"ui.checkScript",
"Check Script…",
+3 -1
View File
@@ -29,12 +29,14 @@ pub struct Opening {
}
/// Is a long operation running that should show the busy cursor and status spinner (background
/// save/export with a label, a file opening or being read for Place off the UI thread, or a heavy plug-in filter)?
/// save/export with a label, a file opening or being read for Place off the UI thread, a heavy plug-in filter, or an
/// Image Trace)?
pub fn long_operation_busy(app: &VectorcraftApp) -> bool {
app.background.jobs.iter().any(|j| !j.label.is_empty() && !j.cancelled())
|| !app.opening.pending.is_empty()
|| !app.plugin_runs.running.is_empty()
|| !app.placing.pending.is_empty()
|| !app.trace_runs.running.is_empty()
}
/// Does opening happen off the UI thread here? Not on wasm (no threads), in unit tests (they look
+27 -11
View File
@@ -263,7 +263,9 @@ pub(crate) fn view_row(app: &mut VectorcraftApp, ui: &mut Ui, current: TraceView
fn trace_with(app: &mut VectorcraftApp, preset: &str) {
// A failure is reported in the status bar. The panel stays as it was: the Image Trace
// button (Properties, Control bar) opens it.
let _ = app.run("imageTrace.make", json!({ "preset": preset }));
if let Err(e) = crate::trace_runs::start(app, "imageTrace.make", json!({ "preset": preset }), false) {
app.status(e);
}
}
/// Reveal the editable tracing controls, including fidelity, in the panel's current location.
@@ -376,20 +378,34 @@ fn trace(app: &mut VectorcraftApp, ctx: &egui::Context, st: &mut TraceUi) {
let all = presets(app, ctx);
let known = find_preset(&all, &st.preset).map(|p| p.0.as_str());
let preset = known.filter(|_| st.preset != "Custom").unwrap_or("Default");
match app.run("imageTrace.make", json!({ "preset": preset, "params": st.params, "view": st.view.id() })) {
Ok(r) => {
st.info = Some((r["paths"].as_u64().unwrap_or(0), r["anchors"].as_u64().unwrap_or(0), r["colors"].as_u64().unwrap_or(0)));
// Adopt the committed state immediately: Undo before the next panel frame must
// still be recognized as a change, even when it returns to the previous settings.
if let Some(stored) = target(app).2 {
st.synced = stored;
st.object = target_key(app);
}
}
let p = json!({ "preset": preset, "params": st.params, "view": st.view.id() });
match crate::trace_runs::start(app, "imageTrace.make", p, true) {
// Traced off the UI thread: the result comes to [`traced`].
Ok(r) if r["background"] == true => {}
Ok(r) => adopt(app, st, &r),
Err(e) => app.ui.status = e,
}
}
/// The panel takes trace `r`'s counts and the traced object's stored settings at once: Undo before
/// the next panel frame must still be recognized as a change, even when it returns to the previous
/// settings.
fn adopt(app: &VectorcraftApp, st: &mut TraceUi, r: &Value) {
st.info = Some((r["paths"].as_u64().unwrap_or(0), r["anchors"].as_u64().unwrap_or(0), r["colors"].as_u64().unwrap_or(0)));
if let Some(stored) = target(app).2 {
st.synced = stored;
st.object = target_key(app);
}
}
/// A trace the panel started off the UI thread was applied with result `r` ([`crate::trace_runs`]).
pub(crate) fn traced(app: &VectorcraftApp, r: &Value) {
let Some(ctx) = app.ui_ctx.clone() else { return };
let mut st: TraceUi = pstate(&ctx, "image-trace");
adopt(app, &mut st, r);
set_pstate(&ctx, "image-trace", st);
}
fn slider(ui: &mut Ui, label: &str, v: &mut f64, range: std::ops::RangeInclusive<f64>, suffix: &str) -> (bool, bool) {
let mut out = (false, false);
ui.horizontal(|ui| {
@@ -1213,3 +1213,42 @@ fn ignore_color_rejects_signs_in_hex() {
assert_eq!(ignore_color(&json!({"ignoreColor": "+0+0+0"})), [255; 3]);
assert_eq!(ignore_color(&json!({"ignoreColor": "#ff00ff"})), [255, 0, 255]);
}
/// #3098: a trace the panel starts off the UI thread is the panel's own once it lands: its counts
/// show, and nothing waits for the object it replaced, so Undo with Preview on shows that object's
/// own settings and traces nothing.
#[test]
fn a_trace_off_the_ui_thread_is_adopted_when_it_lands() {
let mut app = traced_app();
app.trace_runs.off_the_ui_thread = true;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
app.adopt_context(&ctx);
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let strokes = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["strokes"].clone());
let steps = undo(&app);
click(&mut app, &ctx, "Preview");
click(&mut app, &ctx, "Strokes");
click(&mut app, &ctx, "Trace");
assert_eq!(app.trace_runs.running.len(), 1);
assert_eq!(undo(&app), steps, "not traced yet");
let t0 = std::time::Instant::now();
while !app.trace_runs.running.is_empty() && t0.elapsed() < std::time::Duration::from_secs(30) {
crate::trace_runs::poll(&mut app);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(undo(&app), steps + 1, "one undo step");
assert_eq!(strokes(&app), Some(json!(true)));
let st: TraceUi = pstate(&ctx, "image-trace");
assert!(st.info.is_some(), "its counts show");
assert_eq!(st.object, target_key(&app));
assert!(st.drafts.is_empty(), "nothing waits for the object it replaced");
app.ui.image_trace_preview = true;
frame(&mut app, &ctx, vec![]);
app.run("edit.undo", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
assert!(app.trace_runs.running.is_empty(), "Undo traces nothing");
assert_eq!(strokes(&app), Some(json!(false)), "the object's own settings");
assert_eq!(undo(&app), steps);
}
+1
View File
@@ -52,5 +52,6 @@ mod sysclip_probe;
mod tab_overflow;
mod taskbar;
mod toolescape;
mod trace_runs;
mod transparencygrid;
mod widthtool;
+161
View File
@@ -0,0 +1,161 @@
//! Image Trace in the app ([`crate::trace_runs`], #3098): a trace the user asks for runs off the UI
//! thread and is applied as one undo step once it is done, unless its image changed meanwhile. A
//! newer trace of the same object replaces it; Cancel stops it; its progress window shows it.
use std::time::{Duration, Instant};
use serde_json::json;
use vectorcraft_engine::{EngineError, Session};
use crate::{VectorcraftApp, trace_runs};
/// An app with a 100 × 100 pt document and a 40 × 40 px image placed in it, selected; traces run
/// off the UI thread.
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let img = image::RgbaImage::from_fn(40, 40, |x, y| image::Rgba(if x < 20 && y < 30 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let data = vectorcraft_format::base64_encode(&png);
app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap();
app.trace_runs.off_the_ui_thread = true;
app
}
/// Applies the traces' results as the frames would, until none is left (at most 30 s).
fn wait(app: &mut VectorcraftApp) {
let t0 = Instant::now();
while !app.trace_runs.running.is_empty() && t0.elapsed() < Duration::from_secs(30) {
trace_runs::poll(app);
std::thread::sleep(Duration::from_millis(5));
}
assert!(app.trace_runs.running.is_empty(), "still running");
}
/// Undo steps of the active document.
fn undos(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().history.undo.len()
}
/// Draws the progress window at `time` seconds → the texts it drew.
fn progress(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> String {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| trace_runs::show(app, ui.ctx()));
out.textures_delta.clear();
let mut texts = String::new();
for shape in out.shapes {
if let egui::epaint::Shape::Text(t) = shape.shape {
texts.push_str(t.galley.text());
texts.push('\n');
}
}
texts
}
/// Object › Image Trace › Make traces in the background, the status bar saying so, and its result is
/// one undo step, the Image Trace object selected.
#[test]
fn make_traces_off_the_ui_thread_as_one_undo_step() {
let mut app = app();
let before = undos(&app);
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"}));
assert_eq!(app.trace_runs.running.len(), 1);
assert_eq!(app.ui.status, "Tracing the image…");
assert_eq!(undos(&app), before, "nothing yet");
wait(&mut app);
let st = app.session.doc().unwrap();
assert_eq!(st.history.undo.len(), before + 1, "one undo step");
assert_eq!(st.history.undo.last().unwrap().label, "Image Trace");
let [id] = st.selection.objects[..] else { panic!("{:?}", st.selection.objects) };
assert_eq!(st.doc.node(id).unwrap().name.as_deref(), Some("Image Trace"));
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
// Make and Expand too.
crate::menus::invoke(&mut app, "imageTrace.makeAndExpand", json!({"preset": "Default"}));
assert_eq!(app.trace_runs.running.len(), 1);
wait(&mut app);
assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Image Trace (Make and Expand)");
}
/// Without threads (the web, `VECTORCRAFT_SYNC_OPEN`, unit tests that don't ask), Make traces at once.
#[test]
fn without_threads_make_traces_at_once() {
let mut app = app();
app.trace_runs.off_the_ui_thread = false;
let before = undos(&app);
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"}));
assert!(app.trace_runs.running.is_empty());
assert_eq!(undos(&app), before + 1);
}
/// Nothing to trace fails at once, before anything runs.
#[test]
fn nothing_to_trace_fails_at_once() {
let mut app = app();
app.run("select.none", json!({})).unwrap();
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"}));
assert!(app.trace_runs.running.is_empty());
assert!(!app.ui.status.is_empty(), "the status bar says why");
}
/// A second trace of the same object replaces the one running: only it is applied, and the first
/// ends without a word.
#[test]
fn a_new_trace_of_the_same_object_replaces_the_one_running() {
let mut app = app();
let before = undos(&app);
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"}));
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Black and White Logo"}));
assert_eq!(app.trace_runs.running.len(), 2);
wait(&mut app);
let st = app.session.doc().unwrap();
assert_eq!(st.history.undo.len(), before + 1, "the newer trace alone");
let [id] = st.selection.objects[..] else { panic!() };
let stored = st.doc.node(id).unwrap().extras().trace.as_deref().cloned().unwrap();
assert_eq!(stored["preset"], "Black and White Logo");
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
}
/// Cancel (the progress window's, `imageTrace.cancelRuns`) changes nothing, even when the trace
/// had ended just before it.
#[test]
fn cancel_changes_nothing() {
let mut app = app();
let before = undos(&app);
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"}));
assert_eq!(app.run("imageTrace.cancelRuns", json!({})).unwrap(), json!({"cancelled": 1}));
wait(&mut app);
assert_eq!(undos(&app), before);
assert_eq!(app.ui.status, EngineError::Cancelled.to_string());
}
/// An image moved while it is traced doesn't take the trace: nothing changes, and the status bar
/// says to trace it again.
#[test]
fn a_trace_needs_its_image_as_it_was() {
let mut app = app();
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"}));
app.run("object.move", json!({"dx": 5, "dy": 0})).unwrap();
let before = undos(&app);
wait(&mut app);
assert_eq!(undos(&app), before, "nothing changes");
assert!(app.ui.status.contains("changed meanwhile: trace it again"), "{}", app.ui.status);
}
/// The progress window opens after its delay with Cancel and Continue in Background; Continue in
/// Background closes it and the trace goes on.
#[test]
fn the_progress_window_shows_a_trace() {
let mut app = app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"}));
assert!(!progress(&mut app, &ctx, 0.0).contains("Tracing the image"), "not before the delay");
progress(&mut app, &ctx, 1.0); // a new window measures itself unseen first
let shown = progress(&mut app, &ctx, 1.1);
assert!(shown.contains("Tracing the image…") && shown.contains("Cancel") && shown.contains("Continue in Background"), "{shown}");
assert_eq!(app.run("imageTrace.hideRuns", json!({})).unwrap(), json!({"hidden": 1}));
assert!(!progress(&mut app, &ctx, 2.0).contains("Tracing the image"));
let before = undos(&app);
wait(&mut app);
assert_eq!(undos(&app), before + 1, "it went on");
}
+266
View File
@@ -0,0 +1,266 @@
//! Image Trace off the UI thread (#3098). A photo or a large image takes seconds to trace, and the
//! window froze while it did. A trace the user asks for (Object › Image Trace › Make and Make and
//! Expand, the task bar's Image Trace, the Image Trace panel's Trace and presets) is prepared at
//! once, so a bad setting fails before anything runs, and traced on a thread of its own ([`start`])
//! while editing goes on: the status bar shows it, and after [`DELAY`] a progress window shows how
//! many of its colours are traced, with Cancel (also Esc, `imageTrace.cancelRuns`) and Continue in
//! Background (`imageTrace.hideRuns`). A new trace of the same object cancels the one before it.
//!
//! When it is done, what it traced is handed to its command with the same parameters
//! ([`Traced::hand_in`]), so the edit is one undo step like any trace, unless the image or the
//! settings changed meanwhile: then nothing changes and the status bar says to trace it again. The
//! control channel, MCP and scripts run the commands at once, and so does every trace on the web,
//! in unit tests that don't set `off_the_ui_thread`, and with `VECTORCRAFT_SYNC_OPEN` set
//! ([`available`]).
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_doc::cancel::CancelToken;
use vectorcraft_engine::EngineError;
use vectorcraft_engine::cmd::{TraceRun, Traced};
use crate::VectorcraftApp;
use crate::export_progress::{self, Pressed};
pub use crate::export_progress::DELAY;
/// What the status bar and the progress window say.
const LABEL: &str = "Tracing the image";
/// What a trace's thread hands back: the run, and what it traced or why it has nothing.
type Done = (TraceRun, Result<Traced, String>);
/// A trace running.
pub struct Running {
id: u64,
/// The document it traces in ([`vectorcraft_engine::DocState::uid`]) and the object.
doc: u64,
target: NodeId,
/// Its Cancel.
token: CancelToken,
/// A newer trace of the same object took its place: it ends without a word.
superseded: bool,
/// Colour layers traced and in all (0 while the image is prepared).
steps: Arc<[AtomicU64; 2]>,
/// The Image Trace panel started it, and takes its result ([`crate::panels::image_trace::traced`]).
from_panel: bool,
/// Continue in Background: the progress window no longer shows it.
pub hidden: bool,
done: Receiver<Done>,
}
impl Running {
/// Does the progress window show it: neither hidden (Continue in Background) nor cancelled?
fn shown(&self) -> bool {
!self.hidden && !self.token.is_cancelled()
}
/// The share of its colour layers traced, once they are known.
fn fraction(&self) -> Option<f32> {
let [done, total] = &*self.steps;
let (done, total) = (done.load(Ordering::Relaxed), total.load(Ordering::Relaxed));
(total > 0).then(|| done.min(total) as f32 / total as f32)
}
}
/// The traces running.
#[derive(Default)]
pub struct TraceRuns {
pub running: Vec<Running>,
next_id: u64,
/// Set by unit tests whose app traces off the UI thread.
pub(crate) off_the_ui_thread: bool,
}
/// Does `app` trace off the UI thread? Where documents open off it ([`crate::opening::available`]),
/// and in the unit tests that set `off_the_ui_thread`.
pub fn available(app: &VectorcraftApp) -> bool {
app.trace_runs.off_the_ui_thread || crate::opening::available()
}
/// Traces with `command` (`imageTrace.make` or `imageTrace.makeAndExpand`) and its params `p` on a
/// thread of its own → `{background: true, status}`; `from_panel` when the Image Trace panel asks.
/// Where no thread can run it, at once (the command's result).
pub fn start(app: &mut VectorcraftApp, command: &str, p: Value, from_panel: bool) -> Result<Value, String> {
if !available(app) {
return app.run(command, p);
}
// Bad settings and nothing to trace fail here, before anything runs.
let run = TraceRun::prepare(&app.session, command, &p).map_err(|e| e.to_string())?;
let doc = app.session.doc().map_err(|e| e.to_string())?.uid;
let target = run.target();
for r in app.trace_runs.running.iter_mut().filter(|r| r.doc == doc && r.target == target) {
r.token.cancel();
r.superseded = true;
}
let (tx, done) = channel();
let token = CancelToken::new();
let stop = token.clone();
let steps = Arc::new([AtomicU64::new(0), AtomicU64::new(0)]);
let counts = Arc::clone(&steps);
std::thread::Builder::new()
.name("vectorcraft-trace".into())
.spawn(move || {
let [done_now, total_now] = &*counts;
let report = |done: u64, total: u64| {
done_now.store(done, Ordering::Relaxed);
total_now.store(total, Ordering::Relaxed);
!stop.is_cancelled()
};
let traced = vectorcraft_engine::guard::catch_panic(|| run.run(&report))
.map_err(|msg| format!("internal error: {msg} (please report this bug)"))
.and_then(|r| r.map_err(|e| e.to_string()));
// The app may have quit meanwhile: nothing to hand it to.
let _ = tx.send((run, traced));
})
.map_err(|e| e.to_string())?;
let runs = &mut app.trace_runs;
runs.next_id += 1;
runs.running.push(Running { id: runs.next_id, doc, target, token, superseded: false, steps, from_panel, hidden: false, done });
let status = format!("{}…", crate::i18n::msg(LABEL));
app.status(status.clone());
Ok(json!({ "background": true, "status": status }))
}
/// Applies the traces that are done (each frame), in the order they were started. Not while a
/// dialog is open (its OK acts on the active document, which a trace's result changes), nor while
/// an interaction is open or a tool drags with the pointer button held, as [`crate::placing::poll`].
pub fn poll(app: &mut VectorcraftApp) {
if app.ui.dialog.is_some() || app.session.in_interaction() || (app.session.tool_busy() && app.session.pointer_held()) {
return;
}
let mut i = 0;
while let Some(r) = app.trace_runs.running.get(i) {
let done = match r.done.try_recv() {
Ok(done) => Some(done),
Err(TryRecvError::Empty) => {
i += 1;
continue;
}
Err(TryRecvError::Disconnected) => None,
};
let r = app.trace_runs.running.remove(i);
// The status bar no longer says it is tracing.
if app.ui.status == format!("{}…", crate::i18n::msg(LABEL)) {
app.ui.status.clear();
}
finish(app, r, done);
}
}
/// Applies trace `r`'s result in its document, which becomes the active one again, as its command
/// would while the object, its image and the settings are as they were; else why it has none, in
/// the status bar.
fn finish(app: &mut VectorcraftApp, r: Running, done: Option<Done>) {
// Cancel changes nothing, even when the trace ended just before it.
if r.token.is_cancelled() {
if !r.superseded {
app.status(EngineError::Cancelled.to_string());
}
return;
}
let (run, traced) = match done {
Some((run, Ok(traced))) => (run, traced),
Some((_, Err(e))) => {
app.status(format!("Image Trace failed: {e}"));
return;
}
None => {
app.status("Image Trace failed: internal error: its thread stopped (please report this bug)");
return;
}
};
let Some(i) = app.session.documents().iter().position(|st| st.uid == r.doc) else {
app.status("Image Trace finished, but its document was closed");
return;
};
app.session.set_active(i);
if !run.current(&app.session) {
app.status("Image Trace finished, but the image or its settings changed meanwhile: trace it again");
return;
}
traced.hand_in(&mut app.session);
match app.run(run.command(), run.params().clone()) {
Ok(v) if r.from_panel => crate::panels::image_trace::traced(app, &v),
Ok(_) => {}
Err(e) => app.status(format!("Image Trace failed: {e}")),
}
}
/// The progress window's Cancel (also Esc): every trace running stops at its next colour and
/// changes nothing → `{cancelled: n}`.
pub fn cancel_all(app: &mut VectorcraftApp) -> Value {
let mut n = 0;
for r in app.trace_runs.running.iter().filter(|r| !r.token.is_cancelled()) {
r.token.cancel();
n += 1;
}
json!({ "cancelled": n })
}
/// The progress window's Continue in Background: the traces go on, shown in the status bar only
/// → `{hidden: n}`.
pub fn hide_all(app: &mut VectorcraftApp) -> Value {
let mut n = 0;
for r in app.trace_runs.running.iter_mut().filter(|r| r.shown()) {
r.hidden = true;
n += 1;
}
json!({ "hidden": n })
}
/// Is a trace running that hasn't been cancelled?
pub fn any_running(app: &VectorcraftApp) -> bool {
app.trace_runs.running.iter().any(|r| !r.token.is_cancelled())
}
/// Is a trace running that the progress window shows?
pub fn any_shown(app: &VectorcraftApp) -> bool {
app.trace_runs.running.iter().any(Running::shown)
}
/// The progress window of the first trace it shows, once that has run for [`DELAY`]. Not while a
/// dialog, the export progress window or a plug-in's is open: never two modals at once.
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(first) = app.trace_runs.running.iter().find(|r| r.shown()) else { return };
let now = ctx.input(|i| i.time);
let since = ctx.data_mut(|m| {
let seen = m.get_temp_mut_or_insert_with(egui::Id::unique("trace-run-seen"), || (first.id, now));
if seen.0 != first.id {
*seen = (first.id, now);
}
seen.1
});
let exporting = app.background.jobs.iter().any(|j| j.progress.is_some() && !j.hidden && !j.cancelled());
if now - since < DELAY || app.ui.dialog.is_some() || exporting || crate::plugin_runs::any_shown(app) {
return;
}
let fraction = first.fraction();
let t = crate::theme::Tokens::get(ctx);
let heading = format!("{}…", crate::i18n::msg(LABEL));
let pressed = export_progress::window(ctx, "trace-run-progress", &heading, |ui| match fraction {
Some(frac) => {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Tracing the colors…")).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(format!("{:.0} %", frac * 100.0)).color(t.text_dim));
});
});
ui.add_space(4.0);
ui.add(egui::ProgressBar::new(frac).desired_width(360.0).desired_height(6.0).fill(t.accent));
}
None => export_progress::busy(ui, tl!("Preparing the image…")),
});
let id = match pressed {
Some(Pressed::Cancel) => "imageTrace.cancelRuns",
Some(Pressed::Hide) => "imageTrace.hideRuns",
None => return,
};
if let Err(e) = app.run(id, json!({})) {
app.status(e);
}
}
+11 -1
View File
@@ -81,6 +81,15 @@ most 16,384 pixels (at most six times). A trace that would
make more than 8,000,000 anchor points (2,000,000 on the web) is refused to protect memory, with advice:
raise Noise, lower Paths or Colors, or pick a lower-fidelity preset. A trace keeps
about 90 bytes an anchor; a 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million.
**Tracing in the background.** On the desktop a trace runs on a thread of its own, so the window
keeps answering while a photo or a large image is traced (#3098). Make, Make and Expand, the task
bar's Image Trace and the panel's Trace and presets start it. The status bar says "Tracing the
image…", and after 0.4 s a progress window shows how many of its colours are traced, with Cancel
(also Esc, `imageTrace.cancelRuns`) and Continue in Background (`imageTrace.hideRuns`). The result is
one undo step, as before. A new trace of the same object replaces one still running. A trace whose
image or settings changed meanwhile changes nothing, and the status bar says to trace it again.
`imageTrace.make` and `imageTrace.makeAndExpand` from MCP, scripts and the control channel trace at
once and return their result, as every trace on the web does.
These controls use VectorCraft's existing tracing algorithm. Parameter effects follow its
implementation; they do not claim pixel-identical results with another application.
The terminology and general control behavior are described in the
@@ -88,4 +97,5 @@ The terminology and general control behavior are described in the
Regression coverage: `crates/ui-egui/src/panels/image_trace.rs` tests opening the panel,
a quick trace leaving it as it was, repeated opening, floating ownership, slider commits, visible threshold
changes, undo, and the Properties entry point. Tracer tests live in `crates/trace/src/tests.rs`.
changes, undo, and the Properties entry point. Tracing in the background:
`crates/ui-egui/src/tests/trace_runs.rs`. Tracer tests live in `crates/trace/src/tests.rs`.