diff --git a/crates/vtracer/src/compose/mod.rs b/crates/vtracer/src/compose/mod.rs index 0a86861..fe66c60 100644 --- a/crates/vtracer/src/compose/mod.rs +++ b/crates/vtracer/src/compose/mod.rs @@ -5,9 +5,11 @@ //! * **Mosaic** — a seam-free gapless tessellation with shared boundary //! geometry (see [`crate::mosaic`]). +use crate::error::Error; use crate::fitter::CurveFitter; use crate::ir::{Segmentation, Shape, VectorDoc}; use crate::mosaic::{compose_mosaic, SegmentFitter}; +use crate::progress::{Ctx, Phase}; /// Which compositing strategy the pipeline uses. Each variant owns its fitter. pub enum Compositing { @@ -25,6 +27,49 @@ impl Compositing { Compositing::Mosaic(fitter) => compose_mosaic(seg, fitter.as_ref()), } } + + /// Progress- and cancellation-aware compositing. + /// + /// Stacked mode reports per-layer progress and can be cancelled between + /// layers. Mosaic builds its boundary graph in one pass, so it reports + /// coarsely (start/end) and is cancellable only at the boundaries — the + /// dominant cost is upstream in clustering, which cancels finely. + pub fn compose_with(&self, seg: &Segmentation, ctx: &mut Ctx) -> Result { + match self { + Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), ctx), + Compositing::Mosaic(fitter) => { + ctx.check()?; + ctx.report(Phase::Compose, 0.0); + let doc = compose_mosaic(seg, fitter.as_ref()); + ctx.check()?; + ctx.report(Phase::Compose, 1.0); + Ok(doc) + } + } + } +} + +/// Progress-aware [`compose_stacked`]: reports after each layer and checks for +/// cancellation between them. +fn compose_stacked_with( + seg: &Segmentation, + fitter: &dyn CurveFitter, + ctx: &mut Ctx, +) -> Result { + let mut doc = VectorDoc::new(seg.width, seg.height); + let total = seg.layers.len().max(1); + for (i, layer) in seg.layers.iter().enumerate() { + ctx.check()?; + let path = fitter.fit_region(&layer.mask); + if !path.is_empty() { + doc.shapes.push(Shape { + paint: layer.paint, + path, + }); + } + ctx.report(Phase::Compose, (i + 1) as f32 / total as f32); + } + Ok(doc) } /// Trace every layer's closed outline and stack the shapes in paint order. diff --git a/crates/vtracer/src/error.rs b/crates/vtracer/src/error.rs index 0ebaeb8..a4f9fa3 100644 --- a/crates/vtracer/src/error.rs +++ b/crates/vtracer/src/error.rs @@ -9,6 +9,8 @@ pub enum Error { NoKeyColor, /// A requested feature is recognized but not yet implemented. Unsupported(String), + /// The run was aborted via a [`crate::progress::CancelToken`]. + Cancelled, /// Any other failure, carrying a human-readable message. Other(String), } @@ -21,6 +23,7 @@ impl fmt::Display for Error { write!(f, "unable to find an unused color in image to use as key") } Error::Unsupported(what) => write!(f, "unsupported: {what}"), + Error::Cancelled => write!(f, "conversion cancelled"), Error::Other(msg) => write!(f, "{msg}"), } } diff --git a/crates/vtracer/src/frontend/color_cluster.rs b/crates/vtracer/src/frontend/color_cluster.rs index 858ffe3..3991dcd 100644 --- a/crates/vtracer/src/frontend/color_cluster.rs +++ b/crates/vtracer/src/frontend/color_cluster.rs @@ -1,8 +1,14 @@ -use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX}; +use visioncortex::color_clusters::{ + Clusters, KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX, +}; use visioncortex::{Color, ColorImage, PointI32}; +// (Runner is constructed inline in each entry point so its generic closure +// types never appear in a return signature.) + use crate::error::Error; use crate::ir::{Layer, Paint, RegionMask, Segmentation}; +use crate::progress::{Ctx, Phase}; use super::keying::{apply_key, find_unused_color, should_key_image}; use super::Frontend; @@ -28,8 +34,12 @@ impl Default for ColorClusterFrontend { } } -impl Frontend for ColorClusterFrontend { - fn segment(&self, img: &ColorImage) -> Result { +impl ColorClusterFrontend { + /// Apply transparency keying (if warranted) and build the clustering + /// inputs: the keyed image, the `RunnerConfig`, and the dimensions. The + /// caller constructs `Runner::new(config, image)` inline so the runner's + /// generic closure types never surface in a return signature. + fn prepare(&self, img: &ColorImage) -> Result<(ColorImage, RunnerConfig, usize, usize), Error> { if img.width == 0 || img.height == 0 { return Err(Error::EmptyImage); } @@ -48,26 +58,26 @@ impl Frontend for ColorClusterFrontend { Color::default() }; - let runner = Runner::new( - RunnerConfig { - diagonal: self.layer_difference == 0, - hierarchical: HIERARCHICAL_MAX, - batch_size: 25600, - good_min_area: self.filter_speckle_area, - good_max_area: width * height, - is_same_color_a: self.color_precision_loss, - is_same_color_b: 1, - deepen_diff: self.layer_difference, - hollow_neighbours: 1, - key_color, - keying_action: KeyingAction::Discard, - }, - img, - ); + let config = RunnerConfig { + diagonal: self.layer_difference == 0, + hierarchical: HIERARCHICAL_MAX, + batch_size: 25600, + good_min_area: self.filter_speckle_area, + good_max_area: width * height, + is_same_color_a: self.color_precision_loss, + is_same_color_b: 1, + deepen_diff: self.layer_difference, + hollow_neighbours: 1, + key_color, + keying_action: KeyingAction::Discard, + }; - let clusters = runner.run(); + Ok((img, config, width, height)) + } + + /// Turn finished clusters into the layered [`Segmentation`]. + fn segmentation_from_clusters(clusters: &Clusters, width: usize, height: usize) -> Segmentation { let view = clusters.view(); - let mut seg = Segmentation::new(width as u32, height as u32); // `clusters_output` is top-to-bottom; reverse to get bottom-to-top // paint order for the layer stack. @@ -91,7 +101,33 @@ impl Frontend for ColorClusterFrontend { mask, }); } - - Ok(seg) + seg + } +} + +impl Frontend for ColorClusterFrontend { + fn segment(&self, img: &ColorImage) -> Result { + let (image, config, width, height) = self.prepare(img)?; + let clusters = Runner::new(config, image).run(); + Ok(Self::segmentation_from_clusters(&clusters, width, height)) + } + + fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result { + let (image, config, width, height) = self.prepare(img)?; + + // Drive clustering incrementally so we can publish progress and observe + // cancellation between batches. `run()` is exactly this loop, so the + // resulting clusters are identical to the blocking path. + let mut builder = Runner::new(config, image).start(); + ctx.report(Phase::Segment, 0.0); + while !builder.tick() { + ctx.check()?; + ctx.report(Phase::Segment, builder.progress() as f32 / 100.0); + } + ctx.check()?; + let clusters = builder.result(); + ctx.report(Phase::Segment, 1.0); + + Ok(Self::segmentation_from_clusters(&clusters, width, height)) } } diff --git a/crates/vtracer/src/frontend/mod.rs b/crates/vtracer/src/frontend/mod.rs index 2f09d79..2bbc8d5 100644 --- a/crates/vtracer/src/frontend/mod.rs +++ b/crates/vtracer/src/frontend/mod.rs @@ -19,8 +19,23 @@ use visioncortex::ColorImage; use crate::error::Error; use crate::ir::Segmentation; +use crate::progress::Ctx; /// A frontend segments a raster image into ordered paint layers. pub trait Frontend { fn segment(&self, img: &ColorImage) -> Result; + + /// Progress- and cancellation-aware segmentation. + /// + /// The default runs [`segment`](Frontend::segment) and then honors + /// cancellation (coarse: one report at completion, cancel observed after + /// the whole segmentation). Frontends that can step incrementally — like + /// [`ColorClusterFrontend`] — override this to report fine-grained + /// progress and observe cancellation between batches. + fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result { + let seg = self.segment(img)?; + ctx.check()?; + ctx.report(crate::progress::Phase::Segment, 1.0); + Ok(seg) + } } diff --git a/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs index 44234e5..f53354b 100644 --- a/crates/vtracer/src/lib.rs +++ b/crates/vtracer/src/lib.rs @@ -39,11 +39,13 @@ pub mod ir; pub mod mosaic; pub mod optimize; pub mod pipeline; +pub mod progress; pub mod svg; pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset}; pub use error::Error; pub use pipeline::Pipeline; +pub use progress::{CancelToken, Phase, Progress}; // Re-export the visioncortex value types callers need at the boundary. pub use visioncortex::{Color, ColorImage, PointF64, PointI32}; diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs index 1d0bac3..c1c2cc1 100644 --- a/crates/vtracer/src/pipeline.rs +++ b/crates/vtracer/src/pipeline.rs @@ -8,6 +8,7 @@ use crate::error::Error; use crate::frontend::Frontend; use crate::ir::VectorDoc; use crate::optimize::OptimizerPass; +use crate::progress::{CancelToken, Ctx, Phase, Progress}; use crate::svg::SvgWriter; /// A fully-assembled vectorization pipeline. Build one with @@ -22,18 +23,45 @@ pub struct Pipeline { impl Pipeline { /// Run the pipeline to the output document IR (before serialization). + /// + /// Equivalent to [`run_with_progress`](Pipeline::run_with_progress) with a + /// fresh (never-cancelled) token and a no-op progress callback. pub fn run(&self, img: &ColorImage) -> Result { - let mut seg = self.frontend.segment(img)?; + self.run_with_progress(img, &CancelToken::new(), &mut |_| {}) + } + + /// Run the pipeline, publishing [`Progress`] updates and honoring the + /// [`CancelToken`]. + /// + /// Intended to be called on a worker thread: hand a clone of `cancel` to + /// the UI so a button can abort, and forward `on_progress` to a channel + /// that drives a progress bar. Returns [`Error::Cancelled`] if the token is + /// tripped. See [`crate::progress`] for a usage example. + pub fn run_with_progress( + &self, + img: &ColorImage, + cancel: &CancelToken, + on_progress: &mut dyn FnMut(Progress), + ) -> Result { + let mut ctx = Ctx::new(cancel, on_progress); + + let mut seg = self.frontend.segment_with(img, &mut ctx)?; for fitter in &self.color_fitters { fitter.fit(&mut seg); + ctx.check()?; } - let mut doc = self.compositing.compose(&seg); + let mut doc = self.compositing.compose_with(&seg, &mut ctx)?; - for pass in &self.optimizers { + let total = self.optimizers.len().max(1); + for (i, pass) in self.optimizers.iter().enumerate() { + ctx.check()?; pass.run(&mut doc); + ctx.report(Phase::Optimize, (i + 1) as f32 / total as f32); } + // Always emit a terminal 100% so a UI can settle even with no passes. + ctx.report(Phase::Optimize, 1.0); Ok(doc) } diff --git a/crates/vtracer/src/progress.rs b/crates/vtracer/src/progress.rs new file mode 100644 index 0000000..5b341fc --- /dev/null +++ b/crates/vtracer/src/progress.rs @@ -0,0 +1,113 @@ +//! Progress reporting and cancellation for long-running conversions. +//! +//! [`crate::Pipeline::run_with_progress`] takes a [`CancelToken`] and a +//! progress callback. On native targets, run it on a worker thread: the +//! callback publishes [`Progress`] to the UI and the token lets the UI abort +//! between work batches (clustering checks once per batch, so cancellation is +//! near-instant). The pipeline returns [`Error::Cancelled`] when the token is +//! tripped. +//! +//! There is deliberately no cooperative `tick()` here: that only existed in the +//! old browser build because the main thread could not block. The same API +//! works unchanged from a Web Worker. +//! +//! ```no_run +//! use vtracer::{Config, ColorImage}; +//! use vtracer::progress::{CancelToken, Progress}; +//! +//! # fn load() -> ColorImage { todo!() } +//! let pipeline = Config::default().build().unwrap(); +//! let cancel = CancelToken::new(); +//! # let img: ColorImage = load(); +//! // hand `cancel.clone()` to the UI so a button can call `cancel.cancel()` +//! let mut on_progress = |p: Progress| eprintln!("{:?} {:.0}%", p.phase, p.fraction * 100.0); +//! let doc = pipeline.run_with_progress(&img, &cancel, &mut on_progress); +//! ``` + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::error::Error; + +/// A cheaply-clonable cancellation flag shared between the UI and the worker. +/// +/// Clone it, hand one copy to the worker thread running the pipeline and keep +/// the other; call [`cancel`](CancelToken::cancel) from any thread to request +/// an early stop. Clones share the same underlying flag. +#[derive(Clone, Default)] +pub struct CancelToken(Arc); + +impl CancelToken { + /// A fresh, un-cancelled token. + pub fn new() -> Self { + Self::default() + } + + /// Request cancellation. Idempotent; safe to call from any thread. + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + + /// Whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// Which pipeline phase a [`Progress`] update belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + /// Frontend segmentation (color clustering) — usually the dominant cost. + Segment, + /// Compositing the segmentation into shapes. + Compose, + /// Output optimization passes. + Optimize, +} + +/// A progress update: the current [`Phase`] and how far through it we are. +/// +/// `fraction` is *within* the phase, in `0.0..=1.0`. Clustering dominates +/// runtime, so a UI can weight the phases or simply show the phase label with +/// its fraction (e.g. "Clustering 45%"). +#[derive(Clone, Copy, Debug)] +pub struct Progress { + pub phase: Phase, + pub fraction: f32, +} + +/// Bundles the cancel token and progress sink threaded through the stages. +/// +/// Stages call [`Ctx::check`] between batches to honor cancellation and +/// [`Ctx::report`] to publish progress. +pub struct Ctx<'a> { + cancel: &'a CancelToken, + on_progress: &'a mut dyn FnMut(Progress), +} + +impl<'a> Ctx<'a> { + /// Construct a context from a token and a progress callback. + pub fn new(cancel: &'a CancelToken, on_progress: &'a mut dyn FnMut(Progress)) -> Self { + Self { + cancel, + on_progress, + } + } + + /// Return [`Error::Cancelled`] if cancellation has been requested. + pub fn check(&self) -> Result<(), Error> { + if self.cancel.is_cancelled() { + Err(Error::Cancelled) + } else { + Ok(()) + } + } + + /// Publish a progress update for `phase` at `fraction` (clamped to 0..=1). + pub fn report(&mut self, phase: Phase, fraction: f32) { + (self.on_progress)(Progress { + phase, + fraction: fraction.clamp(0.0, 1.0), + }); + } +} diff --git a/crates/vtracer/tests/progress.rs b/crates/vtracer/tests/progress.rs new file mode 100644 index 0000000..233d0cc --- /dev/null +++ b/crates/vtracer/tests/progress.rs @@ -0,0 +1,98 @@ +//! Progress reporting and cancellation for `Pipeline::run_with_progress`. + +use std::cell::Cell; + +use vtracer::progress::{CancelToken, Phase, Progress}; +use vtracer::{ColorImage, Config, Error}; + +/// A checkerboard of two colors — enough clusters that segmentation runs a few +/// batches, so incremental progress and mid-run cancellation are observable. +fn checker(w: usize, h: usize) -> ColorImage { + let mut pixels = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let c = if (x / 6 + y / 6) % 2 == 0 { + (210u8, 60, 60) + } else { + (60, 90, 200) + }; + pixels.extend_from_slice(&[c.0, c.1, c.2, 255]); + } + } + ColorImage { + pixels, + width: w, + height: h, + } +} + +/// A token cancelled before the run starts trips promptly and yields no doc. +#[test] +fn precancelled_returns_cancelled() { + let img = checker(64, 64); + let pipeline = Config::default().build().unwrap(); + + let cancel = CancelToken::new(); + cancel.cancel(); + + let mut cb = |_p: Progress| {}; + let result = pipeline.run_with_progress(&img, &cancel, &mut cb); + assert_eq!(result.err(), Some(Error::Cancelled)); +} + +/// Cancelling from within the progress callback (on the first Segment report) +/// trips at the next batch boundary and returns `Cancelled`. +#[test] +fn cancel_during_progress_trips() { + let img = checker(96, 96); + let pipeline = Config::default().build().unwrap(); + + let cancel = CancelToken::new(); + let saw_segment = Cell::new(false); + + let mut cb = |p: Progress| { + if p.phase == Phase::Segment { + saw_segment.set(true); + cancel.cancel(); + } + }; + let result = pipeline.run_with_progress(&img, &cancel, &mut cb); + + assert!(saw_segment.get(), "expected at least one Segment report"); + assert_eq!(result.err(), Some(Error::Cancelled)); +} + +/// A successful run reports monotonically within each phase, ends at +/// Optimize=1.0, and produces the same shapes as the plain `run`. +#[test] +fn progress_completes_and_matches_run() { + let img = checker(64, 64); + let pipeline = Config::default().build().unwrap(); + + let cancel = CancelToken::new(); + let last = Cell::new(None::); + let count = Cell::new(0usize); + + let mut cb = |p: Progress| { + assert!( + (0.0..=1.0).contains(&p.fraction), + "fraction out of range: {}", + p.fraction + ); + last.set(Some(p)); + count.set(count.get() + 1); + }; + let doc = pipeline + .run_with_progress(&img, &cancel, &mut cb) + .expect("run should succeed"); + + assert!(count.get() > 0, "expected progress reports"); + let final_p = last.get().expect("a final report"); + assert_eq!(final_p.phase, Phase::Optimize); + assert_eq!(final_p.fraction, 1.0); + + // Incremental clustering yields the same clusters as the blocking path, + // so both entry points produce identical output. + let plain = pipeline.run(&img).expect("plain run should succeed"); + assert_eq!(doc.shapes.len(), plain.shapes.len()); +}