mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(¶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<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, ¶ms, 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(¶ms).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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -52,5 +52,6 @@ mod sysclip_probe;
|
||||
mod tab_overflow;
|
||||
mod taskbar;
|
||||
mod toolescape;
|
||||
mod trace_runs;
|
||||
mod transparencygrid;
|
||||
mod widthtool;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user