diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 0df04d2c4..e9f2f2999 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index d6d51e463..bdfed0175 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -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. diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index c523c5bf8..8d72568e8 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -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, parents: &[Option]) -> 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 { - 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, + /// 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, + /// The settings asked for, which the Image Trace object keeps. + params_json: Value, + preset_label: String, + library: Vec<([u8; 3], Paint)>, + bytes: Arc>, +} + +/// 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 { let mut params = trace_params(s, c, p)?; let view = view_param(c, p)?; let params_json = serde_json::to_value(¶ms).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 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 bool + Sync)) -> Result { + // 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 { + 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 { + 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 { + 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(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 17f10826a..595b22743 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// 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, /// 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. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a1d379d6c..472ce8ab2 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -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, diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 2f56fe1c9..5c7baad6f 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -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] diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 85e6373c6..e68118d63 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -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}; diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index b857e73ae..486eb9c0a 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -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 { + 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 { 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 { +fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { 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 = 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> = 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() { diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index f096a97d0..fc7cd10f6 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -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 diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 6ad909495..f5a10d494 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -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 = seen.iter().filter(|s| s.1 == total && s.0 > 0).map(|s| s.0).collect(); + layers.sort_unstable(); + assert_eq!(layers, (1..=total).collect::>(), "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() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 925936c06..5a46f6157 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 88177849e..305364639 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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. diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 30ef1693e..337023f03 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -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") { diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 10cb2f44a..61bb710dd 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -827,6 +827,8 @@ fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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" => { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 647767cac..da91a9d3f 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -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" diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 99aed8cad..3164f2e7f 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -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…", diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 04dacc0c0..e1949573d 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -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 diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index e0612804f..7e365aba0 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -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, suffix: &str) -> (bool, bool) { let mut out = (false, false); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index b5cba28d2..0850bfefc 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index e38f66872..250f721fd 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -52,5 +52,6 @@ mod sysclip_probe; mod tab_overflow; mod taskbar; mod toolescape; +mod trace_runs; mod transparencygrid; mod widthtool; diff --git a/crates/ui-egui/src/tests/trace_runs.rs b/crates/ui-egui/src/tests/trace_runs.rs new file mode 100644 index 000000000..9fbd1b2d6 --- /dev/null +++ b/crates/ui-egui/src/tests/trace_runs.rs @@ -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"); +} diff --git a/crates/ui-egui/src/trace_runs.rs b/crates/ui-egui/src/trace_runs.rs new file mode 100644 index 000000000..a5bf0fd5e --- /dev/null +++ b/crates/ui-egui/src/trace_runs.rs @@ -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); + +/// 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, +} + +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 { + 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, + 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 { + 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) { + // 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); + } +} diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 0c2b1ae3b..b124de96a 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -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`.