Merge pull request #657 from storytold/pr/land-641

Land #653, #654, #656: Image Trace View modes, Palette and Create Strokes (part of #641)
This commit is contained in:
BFlat 2026-10-09 10:25:04 +07:00 committed by GitHub
commit 147fc113c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1640 additions and 89 deletions

File diff suppressed because one or more lines are too long

View File

@ -40,6 +40,7 @@ pub mod slices;
pub mod style_libs;
pub mod swatches;
pub mod text;
pub mod trace;
use std::collections::BTreeMap;
use std::sync::Arc;
@ -89,6 +90,7 @@ pub use text::{
ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom;

View File

@ -463,7 +463,8 @@ pub struct Node {
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<Box<OpacityMask>>,
/// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them).
/// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows
/// them; `view` is its [`crate::TraceView`] id, absent for the tracing result).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<Box<serde_json::Value>>,
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.

92
crates/doc/src/trace.rs Normal file
View File

@ -0,0 +1,92 @@
//! Image Trace objects' View: how a live trace draws on screen.
//!
//! An Image Trace object is a group whose `trace` holds `{preset, params, view}` (see
//! `imageTrace.make`). The view is a viewing aid only: exports, printing and Expand always use the
//! tracing result, whatever it is set to.
use crate::node::Node;
/// Image Trace › View.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TraceView {
/// The traced shapes as they are filled.
#[default]
Result,
/// The traced shapes, with their outlines over them.
ResultWithOutlines,
/// Only the traced shapes' outlines.
Outlines,
/// The source image, with the traced shapes' outlines over it.
OutlinesWithSource,
/// Only the source image.
Source,
}
impl TraceView {
/// Panel order.
pub const ALL: [Self; 5] = [Self::Result, Self::ResultWithOutlines, Self::Outlines, Self::OutlinesWithSource, Self::Source];
/// The id used in command params and in the saved `trace` object.
pub fn id(self) -> &'static str {
match self {
Self::Result => "tracingResult",
Self::ResultWithOutlines => "tracingResultWithOutlines",
Self::Outlines => "outlines",
Self::OutlinesWithSource => "outlinesWithSourceImage",
Self::Source => "sourceImage",
}
}
/// The view an id names (any case).
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(id))
}
/// The view an Image Trace object's `trace` settings ask for (the tracing result when they
/// name none, or an unknown one).
pub fn of(trace: &serde_json::Value) -> Self {
trace.get("view").and_then(serde_json::Value::as_str).and_then(Self::from_id).unwrap_or_default()
}
/// Whether the source image shows.
pub fn shows_source(self) -> bool {
matches!(self, Self::OutlinesWithSource | Self::Source)
}
/// Whether the traced shapes show filled.
pub fn shows_result(self) -> bool {
matches!(self, Self::Result | Self::ResultWithOutlines)
}
/// Whether the traced shapes' outlines show.
pub fn shows_outlines(self) -> bool {
matches!(self, Self::ResultWithOutlines | Self::Outlines | Self::OutlinesWithSource)
}
}
impl Node {
/// The View of an Image Trace object (the tracing result for anything else, or an unknown id).
pub fn trace_view(&self) -> TraceView {
self.trace.as_deref().map(TraceView::of).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_round_trip_and_unknown_views_show_the_result() {
for v in TraceView::ALL {
assert_eq!(TraceView::from_id(v.id()), Some(v));
assert_eq!(TraceView::from_id(&v.id().to_ascii_uppercase()), Some(v));
assert!(v.shows_result() || v.shows_outlines() || v.shows_source(), "{v:?} shows something");
}
let mut g = Node::group(crate::NodeId(1), vec![]);
assert_eq!(g.trace_view(), TraceView::Result);
g.trace = Some(Box::new(serde_json::json!({"view": "outlines"})));
assert_eq!(g.trace_view(), TraceView::Outlines);
g.trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
assert_eq!(g.trace_view(), TraceView::Result);
}
}

View File

@ -14,8 +14,8 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@ -151,6 +151,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection_or_ids,
trace_expand
),
cmd!(
"imageTrace.setView",
"Image Trace View",
[],
None,
"{id?|ids?: Image Trace objects (default: selection), view: \"tracingResult\"|\"tracingResultWithOutlines\"|\"outlines\"|\"outlinesWithSourceImage\"|\"sourceImage\"} how they draw on screen; exports, printing and Expand keep the tracing result → {ids, view}",
has_selection_or_ids,
trace_set_view
),
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
}),
@ -639,6 +648,13 @@ fn is_trace_group(n: &Node) -> bool {
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
}
/// The `view` param ([`TraceView`] id), if given.
fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) };
let ids = || TraceView::ALL.map(TraceView::id).join(", ");
v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids())))
}
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
@ -648,7 +664,38 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
for (k, val) in src {
o.insert(k.clone(), val.clone());
}
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?;
if !params.fills && !params.strokes {
return Err(bad("imageTrace.make", "Create needs fills, strokes or both"));
}
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels"));
}
Ok(params)
}
/// Document Library in Color mode: look the library's colours up into `params.swatches` → each
/// one as traced (sRGB) with the colour to fill with.
fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
const C: &str = "imageTrace.make";
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
return Ok(vec![]);
}
let key = params.library.as_str();
let colors = super::swatchlib::library_colors(s, key)
.ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
if colors.is_empty() {
return Err(bad(C, format!("`{key}` has no colours")));
}
let library: Vec<([u8; 3], Color)> = colors
.into_iter()
.map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
([r, g, b], c)
})
.collect();
params.swatches = library.iter().map(|l| l.0).collect();
Ok(library)
}
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
@ -658,7 +705,8 @@ pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let params = trace_params(p)?;
let mut params = trace_params(p)?;
let view = view_param(C, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's, else "Custom".
let named = str_param(p, "preset").unwrap_or("Default");
@ -687,11 +735,14 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let library = trace_library(s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
// 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 xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
// Document units per raster pixel (stroke widths).
let unit = xf.determinant().abs().sqrt();
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
let id = s.edit(label, |d, sel| {
@ -714,14 +765,30 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
for tp in res.paths {
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])));
// A library colour fills with the swatch's own colour (its model kept).
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
let paint = Paint::solid(color);
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
Some(width) => Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Round,
join: LineJoin::Round,
..StrokeLayer::new(paint, width * unit)
})],
..Default::default()
},
None => fill_only(paint),
};
children.push(Arc::new(n));
}
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
if !expand {
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json })));
// Tracing again keeps the object's view unless another is asked for.
let view = view.unwrap_or_else(|| src.trace_view());
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() })));
}
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
d.remove(target)?;
@ -741,6 +808,28 @@ fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
Ok(v)
}
fn trace_set_view(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "imageTrace.setView";
let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?;
let targets = trace_targets(s, p)?;
let d = &s.doc()?.doc;
if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) {
return Ok(json!({ "ids": ids_json(&targets), "view": view.id() }));
}
s.edit("Image Trace View", |d, _| {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) {
t.insert("view".into(), json!(view.id()));
} else {
n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() })));
}
}
Ok(())
})?;
Ok(json!({ "ids": ids_json(&targets), "view": view.id() }))
}
fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
let targets = trace_targets(s, p)?;
let ids = s.edit("Release Image Trace", |d, sel| {

View File

@ -276,11 +276,18 @@ pub const DOCUMENT_SWATCHES: &str = "document";
/// [`DOCUMENT_SWATCHES`], of the active document's swatches and colour groups. `None` when there is
/// no such library (or document).
pub fn limit_palette(s: &Session, key: &str) -> Option<Palette> {
Some(Palette::new(library_colors(s, key)?))
}
/// The solid colours of library `key` (an id or name) or, for [`DOCUMENT_SWATCHES`], of the
/// active document's swatches and colour groups. `None` when there is no such library (or
/// document).
pub fn library_colors(s: &Session, key: &str) -> Option<Vec<Color>> {
let colors = |sw: &Swatch| sw.paint.color();
if key == DOCUMENT_SWATCHES {
return Some(Palette::new(s.active()?.doc.swatches_iter().filter_map(colors)));
return Some(s.active()?.doc.swatches_iter().filter_map(colors).collect());
}
Some(Palette::new(library(s, key)?.1.iter().filter_map(colors)))
Some(library(s, key)?.1.iter().filter_map(colors).collect())
}
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "").

View File

@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@ -669,6 +669,82 @@ fn image_trace_make_and_expand_and_color_presets() {
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
}
#[test]
fn image_trace_palettes_and_swatch_libraries() {
let mut s = session();
let quads = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
(true, true) => [230, 20, 20, 255],
(false, true) => [20, 200, 30, 255],
(true, false) => [20, 30, 220, 255],
(false, false) => [250, 230, 10, 255],
});
add_image(&mut s, &quads, Affine::IDENTITY);
let fills = |s: &Session, id: &Value| -> Vec<Color> {
traced_paths(&node(s, NodeId(id.as_u64().unwrap()))).iter().filter_map(|n| n.appearance.fill_paint().color()).collect()
};
// Document Library: every fill is one of the document's swatch colours, as it is.
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap();
let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap();
let got = fills(&s, &r["id"]);
assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}");
let t = node(&s, NodeId(r["id"].as_u64().unwrap())).trace.unwrap();
assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document")));
assert!(t["params"].get("swatches").is_none(), "looked up again at every trace");
// A swatch library by id: the web-safe colours.
let r = s
.execute(
"imageTrace.make",
&json!({"id": r["id"], "preset": "6 Colors", "params": {"palette": "documentLibrary", "library": "web-safe-216", "colors": 3}}),
)
.unwrap();
let got = fills(&s, &r["id"]);
assert!((1..=3).contains(&got.len()), "{got:?}");
assert!(got.iter().all(|c| c.to_rgba8(1.0)[..3].iter().all(|v| v % 51 == 0)), "{got:?}");
// Full Tone and Automatic keep the four flat colours.
let mut id = r["id"].clone();
for palette in ["fullTone", "automatic"] {
let r = s.execute("imageTrace.make", &json!({"id": id, "preset": "6 Colors", "params": {"palette": palette, "colorDetail": 50}})).unwrap();
assert_eq!(r["colors"], 4, "{palette}");
id = r["id"].clone();
}
let mut bad = |p: Value| s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": p})).is_err();
assert!(bad(json!({"palette": "documentLibrary", "library": "No Such Library"})));
assert!(bad(json!({"palette": "rainbow"})));
}
#[test]
fn image_trace_create_strokes_makes_stroked_centre_lines() {
let mut s = session();
// A 3 px wide horizontal line from x = 10 to 90 at y = 50, placed at 2× from (100, 100).
let r =
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut s, &r, Affine::translate((100.0, 100.0)) * Affine::scale(2.0));
let res = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}})).unwrap();
assert_eq!(res["paths"], 1);
let g = NodeId(res["id"].as_u64().unwrap());
let paths = traced_paths(&node(&s, g));
let line = &paths[0];
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
let sp = &line.path_data().unwrap().subpaths[0];
assert!(!sp.closed);
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}");
let t = node(&s, g).trace.unwrap();
assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0)));
// Expand keeps the stroked line.
s.execute("imageTrace.expand", &json!({})).unwrap();
assert!(node(&s, g).children().unwrap()[0].appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
// Create needs Fills or Strokes, and a stroke width.
s.execute("edit.undo", &json!({})).unwrap();
for p in [json!({"fills": false, "strokes": false}), json!({"strokes": true, "strokeWidth": 0}), json!({"strokeWidth": -3})] {
assert!(s.execute("imageTrace.make", &json!({"id": g.0, "params": p})).is_err(), "{p}");
}
}
#[test]
fn image_trace_presets_query_and_errors() {
let mut s = session();
@ -705,6 +781,85 @@ fn image_trace_object_remembers_its_settings() {
assert!(st.doc.node(id).unwrap().trace.is_none());
}
/// A black disc (r = 30 px) on light blue, 100 × 100 px, placed at 2× from (50, 50): traced in
/// Black and White with Ignore White, the blue drops out and one disc (r = 60 pt at (150, 150))
/// remains.
fn disc_on_blue(s: &mut Session) -> NodeId {
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [170, 210, 255, 255] }
});
add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["paths"], 1);
NodeId(r["id"].as_u64().unwrap())
}
/// The document rendered as the canvas draws it (`trace_views`) or as exports do.
fn render_doc(s: &Session, trace_views: bool) -> vectorcraft_render::Rendered {
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), trace_views, ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 300, 300, Affine::IDENTITY, &opts)
}
#[test]
fn image_trace_view_is_stored_kept_and_validated() {
let mut s = session();
let g = disc_on_blue(&mut s);
assert_eq!(node(&s, g).trace.unwrap()["view"], "tracingResult");
let r = s.execute("imageTrace.setView", &json!({"view": "outlinesWithSourceImage"})).unwrap();
assert_eq!((ids(&r), r["view"].as_str()), (vec![g], Some("outlinesWithSourceImage")));
assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource);
// Setting the view it has already adds no undo step.
let steps = s.doc().unwrap().history.undo.len();
s.execute("imageTrace.setView", &json!({"id": g.0, "view": "outlinesWithSourceImage"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// Tracing again keeps it, unless another one is asked for; it survives save and open.
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": {"ignoreWhite": true, "noise": 30}})).unwrap()["id"].as_u64().unwrap());
assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource);
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "view": "SourceImage"})).unwrap()["id"].as_u64().unwrap());
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(back.node(g).unwrap().trace_view(), vectorcraft_doc::TraceView::Source);
// Bad views and other objects are refused.
assert!(s.execute("imageTrace.setView", &json!({"view": "sepia"})).is_err());
assert!(s.execute("imageTrace.setView", &json!({})).is_err());
assert!(s.execute("imageTrace.make", &json!({"view": 3})).is_err());
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert!(s.execute("imageTrace.setView", &json!({"id": a.0, "view": "outlines"})).is_err());
}
#[test]
fn image_trace_views_draw_on_screen_only_and_expand_to_the_result() {
let mut s = session();
let g = disc_on_blue(&mut s);
let dark = |p: [u8; 4]| p[0] < 60 && p[1] < 60 && p[2] < 60;
let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245;
let blue = |p: [u8; 4]| p[0] < 200 && p[2] > 245;
let (centre, beside) = ((150, 150), (70, 70));
let at = |img: &vectorcraft_render::Rendered, (x, y): (u32, u32)| img.pixel(x, y);
// The outline is a 1 px line where the disc's right side is (x = 210, anti-aliased).
let outlined = |img: &vectorcraft_render::Rendered| (205..=215).any(|x| img.pixel(x, 150)[0] < 128);
let mut expanded = None;
for view in vectorcraft_doc::TraceView::ALL {
s.execute("imageTrace.setView", &json!({"id": g.0, "view": view.id()})).unwrap();
let screen = render_doc(&s, true);
let (c, b) = (at(&screen, centre), at(&screen, beside));
assert_eq!(dark(c), view.shows_result() || view.shows_source(), "{view:?}: the disc's middle {c:?}");
assert_eq!(blue(b), view.shows_source(), "{view:?}: the image's background {b:?}");
assert!(view.shows_source() || white(b), "{view:?}: nothing beside the disc {b:?}");
if view == vectorcraft_doc::TraceView::Outlines {
assert!(outlined(&screen), "{view:?}: the disc's outline");
}
// Exports draw the tracing result whatever the view.
let export = render_doc(&s, false);
assert!(dark(at(&export, centre)) && white(at(&export, beside)), "{view:?}: export");
// Expand keeps the traced shapes, as traced, whatever the view.
s.execute("imageTrace.expand", &json!({"id": g.0})).unwrap();
let shapes: Vec<_> = node(&s, g).children().unwrap().iter().map(|c| (c.path_data().cloned(), c.appearance.clone())).collect();
assert_eq!(*expanded.get_or_insert_with(|| shapes.clone()), shapes, "{view:?}: expanded");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals
/// overlapping) merges every region the drags touch into one shape whose outline is only the
/// merged area's border: no edge between merged regions stays in, as a stray line or a spur.

View File

@ -295,10 +295,15 @@ with the {effect} effect.
Method:
1. open_file the image: it becomes a document of its own pixel size.
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
color, then threshold, colors, paths, corners, noise, method abutting or overlapping,
ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers.
color (palette limited, fullTone for photos, automatic, or documentLibrary to trace with
the document's swatches or a swatch `library`), then threshold, colors (colorDetail for
fullTone and automatic), paths, corners, noise, method abutting or overlapping,
ignoreWhite, snapCurvesToLines; for line art, strokes: true (with strokeWidth, the widest
line in pixels) traces lines as stroked centre lines instead of filled outlines. Run
imageTrace.presets to see every preset with its numbers.
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
near 90.
near 90. imageTrace.setView {view: \"outlinesWithSourceImage\"} draws the traced outlines
over the image on screen to compare them (screenshots show it; exports keep the result).
3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
catalogue and each effect's parameters.
4. set_paint over the traced shapes to give the artwork its own colours.

View File

@ -21,7 +21,7 @@ pub mod proof;
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject, TraceView};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
@ -100,6 +100,9 @@ pub struct RenderOptions {
/// Screen view: placed documents draw from cached bitmaps made in the background (see
/// [`placed_document`]); off, they draw exactly, read when needed.
pub progressive_placed: bool,
/// Screen view: Image Trace objects draw as their View asks (outlines, the source image…);
/// off, they draw their tracing result, as exports and printing do.
pub trace_views: bool,
}
/// How edges are rasterized (raster export option).
@ -165,6 +168,7 @@ impl Default for RenderOptions {
highlight_substitutions: false,
anti_alias: AntiAlias::Art,
progressive_placed: false,
trace_views: false,
}
}
}
@ -646,8 +650,10 @@ impl Renderer {
let b = match &a.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => {
let mut acc: Option<Rect> = None;
// An Image Trace object's hidden source image shows in some of its views.
let source = a.trace.is_some().then(|| children.first()).flatten();
for c in children {
if c.visible {
if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) {
acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c));
}
}
@ -883,6 +889,9 @@ impl Renderer {
/// What `n` draws inside its transparency group (`knockout`: its children knock each other out).
fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) {
match &n.kind {
NodeKind::Group { children, clip: false } if f.opts.trace_views && !f.opts.outline && n.trace_view() != TraceView::Result => {
self.draw_trace_view(ctx, f, children, n.trace_view(), knockout)
}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
self.draw_children(ctx, f, children, knockout)
}
@ -955,6 +964,25 @@ impl Renderer {
}
}
/// An Image Trace object's `children` (the hidden source image, then the traced shapes) as
/// `view` shows them on screen.
fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], view: TraceView, knockout: bool) {
let Some((source, shapes)) = children.split_first() else { return };
if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) {
self.draw_node(ctx, f, source, true);
}
if view.shows_result() {
self.draw_children(ctx, f, shapes, knockout);
}
if view.shows_outlines() {
let opts = RenderOptions { outline: true, ..f.opts.clone() };
let frame = Frame { opts: &opts, ..*f };
for c in shapes {
self.draw_node(ctx, &frame, c, false);
}
}
}
/// The children of a group: as the elements of a knockout group when `knockout`.
fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], knockout: bool) {
if knockout {

View File

@ -0,0 +1,379 @@
//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked
//! centre lines instead of filled outlines.
//!
//! 1. **Distance**: a chamfer (3-4) distance transform gives each pixel its distance to the nearest
//! pixel outside the layer.
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
//! one) is within the stroke width is a line; wider ones stay filled areas.
//! 3. **Skeleton**: the line is thinned to one pixel (Zhang–Suen, applied pixel by pixel so it
//! keeps its topology and two-pixel-wide lines don't vanish), then staircase corners that
//! don't connect anything are dropped.
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
//! merged into one point) become polylines through the pixel centres; rings without ends
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
//! on either side of a junction left with two of them are joined.
//! 5. **Curves**: each polyline is fitted like an outline ([`fit::fit_polyline`]); the stroke
//! width is the line's mean width (its pixel count over its length).
use std::collections::HashSet;
use vectorcraft_geom::{PathData, Point};
use crate::fit::{self, FitOptions};
/// One line of a colour layer: its centre lines and the width to stroke them with.
pub(crate) struct Line {
pub path: PathData,
/// Stroke width in pixels.
pub width: f64,
pub pixels: usize,
}
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
/// pixels, and which pixels they cover (to leave out of the filled areas).
pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f64, o: &FitOptions) -> (Vec<bool>, Vec<Line>) {
let mut thin = vec![false; labels.len()];
let mut out = vec![];
if w == 0 || h == 0 || labels.len() != w * h {
return (thin, out);
}
let mask = |i: usize| labels.get(i) == Some(&label);
let dt = distance(&mask, w, h);
let mut seen = vec![false; w * h];
let mut stack = vec![];
for start in 0..w * h {
if !mask(start) || seen[start] {
continue;
}
let mut comp = vec![];
seen[start] = true;
stack.push(start);
while let Some(i) = stack.pop() {
comp.push(i);
for j in neighbours(i, w, h) {
if mask(j) && !seen[j] {
seen[j] = true;
stack.push(j);
}
}
}
let widest = 2.0 * f64::from(comp.iter().map(|&i| dt[i]).max().unwrap_or(0)) / 3.0 - 1.0;
if widest > max_width {
continue;
}
let Some(line) = trace_line(&comp, w, max_width, o) else { continue };
for &i in &comp {
thin[i] = true;
}
out.push(line);
}
(thin, out)
}
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
let (x, y) = ((i % w) as i64, (i / w) as i64);
const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
AROUND.into_iter().filter_map(move |(dx, dy)| {
let (nx, ny) = (x + dx, y + dy);
(nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize)
})
}
/// Chamfer 3-4 distance (3 per pixel step, saturating) of each pixel in `mask` to the nearest
/// pixel outside it; outside the image counts as outside.
fn distance(mask: &impl Fn(usize) -> bool, w: usize, h: usize) -> Vec<u16> {
let mut d = vec![0u16; w * h];
let at = |d: &[u16], x: i64, y: i64| {
if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0 } else { d.get(y as usize * w + x as usize).copied().unwrap_or(0) }
};
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if mask(i) {
let (x, y) = (x as i64, y as i64);
let steps = [(x - 1, y, 3), (x - 1, y - 1, 4), (x, y - 1, 3), (x + 1, y - 1, 4)];
d[i] = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
}
}
}
for y in (0..h).rev() {
for x in (0..w).rev() {
let i = y * w + x;
if mask(i) {
let (xi, yi) = (x as i64, y as i64);
let steps = [(xi + 1, yi, 3), (xi + 1, yi + 1, 4), (xi, yi + 1, 3), (xi - 1, yi + 1, 4)];
let v = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
d[i] = d[i].min(v);
}
}
}
d
}
/// A grid around one line: its pixels with a one-pixel empty border.
struct Grid {
on: Vec<bool>,
w: usize,
h: usize,
/// Image position of grid pixel (0, 0).
x0: i64,
y0: i64,
}
impl Grid {
/// The 8 neighbours (N, NE, E, SE, S, SW, W, NW) of interior pixel `i`.
fn ring(&self, i: usize) -> [bool; 8] {
let w = self.w;
let g = |j: usize| self.on.get(j).copied().unwrap_or(false);
[g(i - w), g(i - w + 1), g(i + 1), g(i + w + 1), g(i + w), g(i + w - 1), g(i - 1), g(i - w - 1)]
}
fn interior(&self) -> impl Iterator<Item = usize> + use<> {
let (w, h) = (self.w, self.h);
(1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x))
}
fn neighbours(&self, i: usize) -> impl Iterator<Item = usize> + '_ {
let w = self.w;
[i - w, i - w + 1, i + 1, i + w + 1, i + w, i + w - 1, i - 1, i - w - 1].into_iter().filter(|&j| self.on.get(j).copied().unwrap_or(false))
}
fn degree(&self, i: usize) -> usize {
self.neighbours(i).count()
}
/// The centre of pixel `i` in image coordinates.
fn centre(&self, i: usize) -> Point {
Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5)
}
}
/// Zhang–Suen thinning. Each sub-iteration picks the pixels to remove from the same state, so the
/// skeleton stays centred; they are then removed one by one, each only if it still doesn't
/// disconnect anything, so a two-pixel-wide line thins to one pixel instead of vanishing.
fn thin(g: &mut Grid) {
let removable = |p: &[bool; 8]| {
let b = p.iter().filter(|v| **v).count();
let a = (0..8).filter(|&k| !p[k] && p[(k + 1) % 8]).count();
(2..=6).contains(&b) && a == 1
};
let mut picked = vec![];
loop {
let mut changed = false;
for step in 0..2 {
picked.clear();
for i in g.interior() {
if !g.on[i] {
continue;
}
let p = g.ring(i);
let (n, e, s, w) = (p[0], p[2], p[4], p[6]);
let side = if step == 0 { !(e && s && (n || w)) } else { !(n && w && (e || s)) };
if side && removable(&p) {
picked.push(i);
}
}
for &i in &picked {
if removable(&g.ring(i)) {
g.on[i] = false;
changed = true;
}
}
}
if !changed {
break;
}
}
// Staircase corners: a pixel whose neighbours stay connected without it (Yokoi's
// 8-connectivity number 1) and that doesn't end the line.
loop {
let mut changed = false;
for i in g.interior() {
if !g.on[i] || g.degree(i) < 2 {
continue;
}
let p = g.ring(i);
// x1 = E, x2 = NE, x3 = N, x4 = NW, x5 = W, x6 = SW, x7 = S, x8 = SE.
let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]];
let off = |k: usize| !x[k % 8];
let c8: usize = [0, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum();
if c8 == 1 {
g.on[i] = false;
changed = true;
}
}
if !changed {
break;
}
}
}
/// A run of skeleton pixels between two nodes (`None`: none, a ring).
#[derive(Clone)]
struct Run {
pts: Vec<Point>,
ends: [Option<usize>; 2],
closed: bool,
}
impl Run {
fn length(&self) -> f64 {
self.pts.windows(2).map(|p| p[0].distance(p[1])).sum::<f64>()
+ if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 }
}
}
/// The centre lines of one line component (pixel indices of the `w`-wide image).
fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Option<Line> {
let xs = comp.iter().map(|&i| i % w);
let ys = comp.iter().map(|&i| i / w);
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
let (gw, gh) = (x1 - x0 + 3, y1 - y0 + 3);
let mut g = Grid { on: vec![false; gw * gh], w: gw, h: gh, x0: x0 as i64 - 1, y0: y0 as i64 - 1 };
for &i in comp {
g.on[(i / w - y0 + 1) * gw + (i % w - x0 + 1)] = true;
}
thin(&mut g);
let skeleton: Vec<usize> = g.interior().filter(|&i| g.on[i]).collect();
if skeleton.len() < 2 {
return None;
}
// Nodes: line ends, and junctions with the junction pixels touching them.
let mut node = vec![usize::MAX; g.on.len()];
let mut centres: Vec<Point> = vec![];
for &i in &skeleton {
let d = g.degree(i);
if d == 2 || node[i] != usize::MAX {
continue;
}
let id = centres.len();
let mut members = vec![i];
node[i] = id;
if d > 2 {
let mut k = 0;
while let Some(&p) = members.get(k) {
k += 1;
for q in g.neighbours(p).collect::<Vec<_>>() {
if node[q] == usize::MAX && g.degree(q) > 2 {
node[q] = id;
members.push(q);
}
}
}
}
let sum = members.iter().fold(Point::ZERO, |s, &m| s + g.centre(m).to_vec2());
centres.push(Point::new(sum.x / members.len() as f64, sum.y / members.len() as f64));
}
// Runs from each node, then the rings left.
let mut used: HashSet<(usize, usize)> = HashSet::new();
let edge = |a: usize, b: usize| (a.min(b), a.max(b));
let mut runs: Vec<Run> = vec![];
for &p in skeleton.iter().filter(|&&i| node[i] != usize::MAX) {
for q in g.neighbours(p).collect::<Vec<_>>() {
if node[q] == node[p] || !used.insert(edge(p, q)) {
continue;
}
let mut pts = vec![centres[node[p]], g.centre(q)];
let (mut prev, mut cur) = (p, q);
while node[cur] == usize::MAX {
let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) else { break };
used.insert(edge(cur, next));
pts.push(g.centre(next));
(prev, cur) = (cur, next);
}
let end = (node[cur] != usize::MAX).then_some(node[cur]);
if let (Some(e), Some(last)) = (end, pts.last_mut()) {
*last = centres[e];
}
runs.push(Run { pts, ends: [Some(node[p]), end], closed: false });
}
}
for &s in &skeleton {
if node[s] != usize::MAX || g.neighbours(s).any(|n| used.contains(&edge(s, n))) {
continue;
}
let mut pts = vec![g.centre(s)];
let (mut prev, mut cur) = (usize::MAX, s);
let mut closed = false;
while let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) {
used.insert(edge(cur, next));
if next == s {
closed = true;
break;
}
pts.push(g.centre(next));
(prev, cur) = (cur, next);
}
runs.push(Run { pts, ends: [None, None], closed });
}
// The line's mean width: its pixels over its length (the skeleton stops about half a width
// short of each end).
let width = |runs: &[Run]| {
let length: f64 = runs.iter().map(Run::length).sum();
let px = comp.len() as f64;
((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0))
};
let spur = width(&runs);
let runs = join(prune(runs, centres.len(), spur), centres.len());
let width = width(&runs);
let subs: Vec<_> = runs.iter().filter_map(|r| fit::fit_polyline(&r.pts, r.closed, o)).collect();
if subs.is_empty() {
return None;
}
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
}
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
/// to a junction that keeps at least two other runs.
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
let mut degree = vec![0usize; nodes];
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
degree[*e] += 1;
}
let mut order: Vec<usize> = (0..runs.len()).collect();
order.sort_by(|a, b| runs[*a].length().total_cmp(&runs[*b].length()));
let mut keep = vec![true; runs.len()];
for i in order {
let r = &runs[i];
let [Some(a), Some(b)] = r.ends else { continue };
let (tip, fork) = if degree[a] == 1 { (a, b) } else { (b, a) };
if degree[tip] == 1 && degree[fork] >= 3 && r.length() < width.max(2.0) {
keep[i] = false;
degree[tip] -= 1;
degree[fork] -= 1;
}
}
runs.into_iter().zip(keep).filter_map(|(r, k)| k.then_some(r)).collect()
}
/// The runs joined through every node exactly two of them meet at (a run meeting itself closes).
fn join(runs: Vec<Run>, nodes: usize) -> Vec<Run> {
let mut runs: Vec<Option<Run>> = runs.into_iter().map(Some).collect();
for n in 0..nodes {
let at: Vec<(usize, usize)> = runs
.iter()
.enumerate()
.flat_map(|(i, r)| r.iter().flat_map(move |r| (0..2).filter(move |&k| r.ends[k] == Some(n)).map(move |k| (i, k))))
.collect();
let [(i, ki), (j, kj)] = at[..] else { continue };
if i == j {
if let Some(r) = runs[i].as_mut() {
r.pts.pop();
r.ends = [None, None];
r.closed = r.pts.len() >= 3;
}
continue;
}
let (Some(mut a), Some(mut b)) = (runs[i].take(), runs[j].take()) else { continue };
// a ends at n, b starts at n.
if ki == 0 {
a.pts.reverse();
a.ends.reverse();
}
if kj == 1 {
b.pts.reverse();
b.ends.reverse();
}
a.pts.extend(b.pts.into_iter().skip(1));
a.ends[1] = b.ends[1];
runs[i] = Some(a);
}
runs.into_iter().flatten().collect()
}

View File

@ -133,6 +133,49 @@ pub(crate) fn fit_loop(l: &Loop, o: &FitOptions) -> Option<SubPath> {
Some(sp)
}
/// Fit a centre line through `pts` (pixel-centre points in order) into a Bézier subpath: the
/// points within `polygon_tol` of it are dropped (Douglas–Peucker), then curves are fitted. A
/// `closed` line runs back to its first point.
pub(crate) fn fit_polyline(pts: &[Point], closed: bool, o: &FitOptions) -> Option<SubPath> {
let n = pts.len();
let first = *pts.first()?;
if n < 2 {
return None;
}
let mut ext = pts.to_vec();
let mut keep = vec![false; n + 1];
keep[0] = true;
if closed {
// Split the ring at its first point and the point farthest from it.
ext.push(first);
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(first).total_cmp(&pts[j].distance_squared(first))).unwrap_or(n / 2);
keep[far] = true;
rdp(&ext, 0, far, o.polygon_tol, &mut keep);
rdp(&ext, far, n, o.polygon_tol, &mut keep);
} else {
keep[n - 1] = true;
rdp(&ext, 0, n - 1, o.polygon_tol, &mut keep);
}
let mut poly: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
if closed && poly.len() < 3 {
poly = pts.to_vec();
}
if poly.len() < if closed { 3 } else { 2 } {
return None;
}
let sp = SubPath::polyline(&poly, closed);
let fitted =
simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false });
let mut sp = fitted.subpaths.into_iter().next()?;
if sp.anchors.len() < 2 {
return None;
}
if o.snap_lines {
snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5));
}
Some(sp)
}
/// Retract the handles of curves that stay within `tol` of their chord.
fn snap_to_lines(sp: &mut SubPath, tol: f64) {
let n = sp.anchors.len();

View File

@ -4,8 +4,11 @@
//! and classic colour quantisation), not from any existing implementation:
//!
//! 1. **Quantise** the image into a small palette: a luminance threshold (Black and White), 1-D
//! k-means over the luminance histogram (Grayscale), or median cut followed by weighted
//! k-means over a 15-bit colour histogram (Color). Transparent pixels are left out.
//! k-means over the luminance histogram (Grayscale), or, in Color, over a 15-bit colour
//! histogram as the [`Palette`] asks: median cut to a colour count followed by weighted k-means
//! (Limited), median cut until every colour box is tight (Full Tone), one of those two by
//! whether the image is flat art (Automatic), or the most used colours of a swatch library
//! (Document Library). Transparent pixels are left out.
//! 2. **Remove noise**: 4-connected same-colour components smaller than `noise` pixels are merged
//! into their most common neighbouring colour.
//! 3. **Follow boundaries**: for each colour layer, the pixel-crack edges between inside and
@ -18,10 +21,15 @@
//! 5. **Curves**: the polygon is fitted with cubic Béziers (`vectorcraft_pathops::simplify_with`,
//! least squares with corner detection); optionally nearly-straight curves snap to lines.
//!
//! With **Create: Strokes**, the parts of a colour layer no wider than the stroke width are traced
//! as stroked centre lines instead (see `centerline`); without Fills, wider parts become stroked
//! outlines.
//!
//! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or
//! *overlapping* (stacked: each layer also covers every layer above it, so no hairline gaps).
#![forbid(unsafe_code)]
mod centerline;
mod contour;
mod fit;
mod mosaic;
@ -34,7 +42,7 @@ use vectorcraft_geom::PathData;
pub use contour::{Component, Loop, trace_mask};
pub use mosaic::mosaic;
pub use quantize::{Quantized, TRANSPARENT, denoise, quantize};
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
/// Errors decoding a raster.
#[derive(Debug, thiserror::Error)]
@ -132,6 +140,28 @@ pub enum Method {
Overlapping,
}
/// Where Color mode's colours come from (the Palette popup).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Palette {
/// The `colors` colours that best represent the image.
#[default]
#[serde(alias = "Limited")]
Limited,
/// As many colours as the image's tones need, up to [`FULL_TONE_MAX`] (photos, gradients):
/// `color_detail` sets how close two colours may be and stay apart.
#[serde(alias = "Full Tone")]
FullTone,
/// Flat art (a few colours cover nearly all of it) traces with exactly those colours, anything
/// else as Full Tone.
#[serde(alias = "Automatic")]
Automatic,
/// The most used colours of a swatch library ([`TraceParams::swatches`]), at most `colors` of
/// them, exactly as they are.
#[serde(alias = "library", alias = "Document Library")]
DocumentLibrary,
}
/// Image Trace parameters (the Image Trace panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@ -139,8 +169,20 @@ pub struct TraceParams {
pub mode: Mode,
/// Black and White: pixels darker than this (luminance 0–255) are black.
pub threshold: u8,
/// Palette size for Color, number of grays for Grayscale (2–256).
/// Color: where the colours come from.
pub palette: Palette,
/// Palette size for Color (Limited; at most this many for Document Library), number of grays
/// for Grayscale (2–256).
pub colors: u32,
/// Full Tone and Automatic: how many colours, 0 (few) – 100 (many).
pub color_detail: f64,
/// Document Library: the swatch library, `"document"` (the document's swatches) or a library id
/// or name. The caller looks its colours up into `swatches`.
pub library: String,
/// Document Library: the library's colours (sRGB). Not saved: they are looked up from
/// `library` at every trace.
#[serde(skip)]
pub swatches: Vec<[u8; 3]>,
/// Paths (fidelity) 0–100: higher follows the pixels more tightly.
pub paths: f64,
/// Corners 0–100: higher keeps more corners.
@ -152,6 +194,12 @@ pub struct TraceParams {
pub ignore_white: bool,
/// Replace nearly-straight curves with straight lines.
pub snap_curves_to_lines: bool,
/// Create: trace areas as filled shapes.
pub fills: bool,
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
pub strokes: bool,
/// Stroke: the widest feature (px) traced as a stroke.
pub stroke_width: f64,
}
impl Default for TraceParams {
@ -160,13 +208,20 @@ impl Default for TraceParams {
Self {
mode: Mode::BlackAndWhite,
threshold: 128,
palette: Palette::Limited,
colors: 6,
color_detail: 50.0,
library: "document".into(),
swatches: vec![],
paths: 50.0,
corners: 75.0,
noise: 25,
method: Method::Abutting,
ignore_white: false,
snap_curves_to_lines: false,
fills: true,
strokes: false,
stroke_width: 10.0,
}
}
}
@ -250,7 +305,8 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> {
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
}
/// One traced shape: an outer contour plus its holes, filled with `color`.
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
/// lines stroked with it.
#[derive(Clone, Debug, PartialEq)]
pub struct TracedPath {
/// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`).
@ -258,6 +314,9 @@ pub struct TracedPath {
pub color: [u8; 3],
/// Filled pixel count of the component the path came from.
pub pixels: usize,
/// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the
/// outlines of wider areas without Fills).
pub stroke: Option<f64>,
}
/// The traced result, bottom-most path first.
@ -357,15 +416,37 @@ impl Layers<'_> {
let (params, opts) = (self.params, &self.opts);
let color = *self.q.palette.get(ci)?;
let overlapping = params.method == Method::Overlapping;
let (w, h) = (self.w, self.h);
// Create Strokes: this colour's lines, left out of its areas.
let (thin, lines) = if params.strokes {
centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, opts)
} else {
(vec![], vec![])
};
let mask: Vec<bool> = self
.q
.labels
.iter()
.map(|&l| l != TRANSPARENT && if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci })
.enumerate()
.map(|(i, &l)| {
l != TRANSPARENT
&& !thin.get(i).copied().unwrap_or(false)
&& if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci }
})
.collect();
// Without Fills, areas are outlined with a 1 px stroke.
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
let min_hole = params.noise.max(1) as i64;
let mut paths = vec![];
for comp in trace_mask(&mask, self.w, self.h) {
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>| {
let n = path.anchor_count();
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
return false;
}
paths.push(TracedPath { path, color, pixels, stroke });
true
};
for comp in trace_mask(&mask, w, h) {
let Some(outer) = fit::fit_loop(&comp.outer, opts) else { continue };
let mut subs = vec![outer];
for hole in &comp.holes {
@ -374,12 +455,14 @@ impl Layers<'_> {
}
subs.extend(fit::fit_loop(hole, opts));
}
let path = PathData::new(subs);
let n = path.anchor_count();
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
if !add(PathData::new(subs), comp.pixels, area_stroke) {
return None;
}
}
for line in lines {
if !add(line.path, line.pixels, Some(line.width)) {
return None;
}
paths.push(TracedPath { path, color, pixels: comp.pixels });
}
Some(paths)
}

View File

@ -1,6 +1,6 @@
//! Colour reduction and noise removal.
use crate::{Mode, Raster, TraceParams};
use crate::{Mode, Palette, Raster, TraceParams};
/// Label of a transparent (untraced) pixel.
pub const TRANSPARENT: u16 = u16::MAX;
@ -67,10 +67,8 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
b.sum[2] += p[2] as u64;
}
}
let used: Vec<(usize, [f64; 3], f64)> =
bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
let k = params.colors.clamp(2, 256) as usize;
let centers = kmeans_rgb(&used, k);
let used: Vec<Used> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
let centers = color_palette(&used, params);
let mut lut = vec![0u16; 1 << 15];
for (i, c, _) in &used {
lut[*i] = nearest_rgb(&centers, *c) as u16;
@ -187,12 +185,108 @@ fn nearest_rgb(centers: &[[f64; 3]], c: [f64; 3]) -> usize {
best.1
}
/// Median cut for the initial palette, then weighted k-means refinement over histogram bins.
fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> {
if used.is_empty() {
return vec![[255.0; 3]];
/// The most colours Full Tone (and Automatic on continuous-tone images) makes.
pub const FULL_TONE_MAX: usize = 256;
/// A histogram bin in use: its index, mean colour and pixel count.
type Used = (usize, [f64; 3], f64);
/// Color mode's palette for the histogram bins `used`, as `params.palette` asks.
fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3]> {
let k = params.colors.clamp(2, 256) as usize;
let detail = (params.color_detail / 100.0).clamp(0.0, 1.0);
// Full Tone: split until no colour box is wider than 96 (few colours) … 8 (many) levels per
// channel, which is one histogram bin.
let full_tone = || refine(used, centers(&median_cut(used, FULL_TONE_MAX, 96.0 - 88.0 * detail)));
match params.palette {
Palette::Limited => kmeans_rgb(used, k),
Palette::FullTone => full_tone(),
Palette::Automatic => match flat_colors(used, detail) {
Some(n) => kmeans_rgb(used, n),
None => full_tone(),
},
Palette::DocumentLibrary if !params.swatches.is_empty() => library_colors(used, &params.swatches, k),
// No library colours to use: as Limited.
Palette::DocumentLibrary => kmeans_rgb(used, k),
}
}
/// Automatic: how many colours flat art is made of, or `None` for continuous tone (photos,
/// gradients). Flat art has most of its pixels in a few tight colours (anti-aliased edges and
/// noise aside): the bins holding 90% of the pixels are few, and each colour's pixels sit mostly
/// in one bin. `detail` (0–1) lets smaller colour areas count as colours of their own.
fn flat_colors(used: &[Used], detail: f64) -> Option<usize> {
let total: f64 = used.iter().map(|u| u.2).sum();
if total <= 0.0 {
return Some(2);
}
let mut by_count: Vec<&Used> = used.iter().collect();
by_count.sort_by(|a, b| b.2.total_cmp(&a.2));
let mut covered = 0.0;
let main = by_count
.iter()
.take_while(|u| {
let before = covered;
covered += u.2;
before < 0.9 * total
})
.count();
if main > FLAT_BINS {
return None;
}
let boxes = median_cut(used, 64, 24.0);
let min_share = (0.04 - 0.035 * detail) * total;
let colours: Vec<&ColorBox> = boxes.iter().filter(|b| b.weight >= min_share).collect();
let weight: f64 = colours.iter().map(|b| b.weight).sum();
let peaks: f64 = colours.iter().map(|b| b.peak).sum();
(weight >= 0.9 * total && peaks >= 0.5 * weight && colours.len() <= 32).then_some(colours.len().max(2))
}
/// The most histogram bins flat art's main colours spread over (a few bins per colour).
const FLAT_BINS: usize = 96;
/// Document Library: the `k` swatches (at most) that the most pixels are nearest to.
fn library_colors(used: &[Used], swatches: &[[u8; 3]], k: usize) -> Vec<[f64; 3]> {
let mut all: Vec<[f64; 3]> = vec![];
for s in swatches {
let c = s.map(f64::from);
if !all.contains(&c) {
all.push(c);
}
}
let mut weight = vec![0.0; all.len()];
for (_, c, n) in used {
if let Some(w) = weight.get_mut(nearest_rgb(&all, *c)) {
*w += n;
}
}
let mut order: Vec<usize> = (0..all.len()).collect();
order.sort_by(|a, b| weight[*b].total_cmp(&weight[*a]).then(a.cmp(b)));
order.into_iter().take(k.max(1)).filter_map(|i| all.get(i).copied()).collect()
}
/// A median-cut box: its weighted mean colour, pixel count and the pixel count of its heaviest bin.
struct ColorBox {
mean: [f64; 3],
weight: f64,
peak: f64,
}
fn centers(boxes: &[ColorBox]) -> Vec<[f64; 3]> {
boxes.iter().map(|b| b.mean).collect()
}
/// Median cut to `k` colours, then weighted k-means refinement over histogram bins.
fn kmeans_rgb(used: &[Used], k: usize) -> Vec<[f64; 3]> {
refine(used, centers(&median_cut(used, k, 0.0)))
}
/// Median cut: repeatedly split the box with the largest weighted extent, while there are fewer
/// than `k` boxes and some box spans more than `spread` levels of a channel.
fn median_cut(used: &[Used], k: usize, spread: f64) -> Vec<ColorBox> {
if used.is_empty() {
return vec![ColorBox { mean: [255.0; 3], weight: 0.0, peak: 0.0 }];
}
// Median cut: repeatedly split the box with the largest weighted extent.
let mut boxes: Vec<Vec<usize>> = vec![(0..used.len()).collect()];
while boxes.len() < k {
let mut best: Option<(f64, usize, usize)> = None;
@ -210,7 +304,7 @@ fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> {
wsum += used[i].2;
}
let (ch, range) = (0..3).map(|c| (c, hi[c] - lo[c])).fold((0, -1.0), |a, b| if b.1 > a.1 { b } else { a });
if range <= 0.0 {
if range <= spread.max(0.0) {
continue;
}
let score = range * wsum.sqrt();
@ -235,21 +329,25 @@ fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> {
boxes.push(b);
boxes.push(tail);
}
let mut centers: Vec<[f64; 3]> = boxes
boxes
.iter()
.map(|b| {
let mut s = [0.0; 3];
let mut w = 0.0;
let (mut w, mut peak) = (0.0, 0.0f64);
for &i in b {
for (acc, v) in s.iter_mut().zip(used[i].1) {
*acc += v * used[i].2;
}
w += used[i].2;
peak = peak.max(used[i].2);
}
[s[0] / w, s[1] / w, s[2] / w]
ColorBox { mean: [s[0] / w, s[1] / w, s[2] / w], weight: w, peak }
})
.collect();
// Lloyd refinement.
.collect()
}
/// Lloyd refinement of `centers` over the bins `used`.
fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
for _ in 0..12 {
let mut sum = vec![[0.0; 3]; centers.len()];
let mut cnt = vec![0.0; centers.len()];

View File

@ -1,4 +1,5 @@
use super::*;
use vectorcraft_geom::Point;
const BLACK: [u8; 4] = [0, 0, 0, 255];
const WHITE: [u8; 4] = [255, 255, 255, 255];
@ -348,3 +349,206 @@ fn an_image_too_large_to_trace_is_refused_before_decoding() {
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}");
assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding");
}
/// Four flat colours with anti-aliased (blended) edges between the quadrants.
fn flat_quads() -> Raster {
const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]];
Raster::from_fn(100, 100, |x, y| {
let at = |x: u32, y: u32| C[usize::from(x >= 50) + 2 * usize::from(y >= 50)];
let (a, b) = (at(x, y), at(x.saturating_sub(1), y.saturating_sub(1)));
// Blend the pixels along the quadrant edges, as an anti-aliased picture has them.
let c: Vec<u8> = a.iter().zip(b).map(|(p, q)| ((u16::from(*p) + u16::from(q)) / 2) as u8).collect();
[c[0], c[1], c[2], 255]
})
}
/// A smooth two-dimensional colour gradient (continuous tone).
fn gradient() -> Raster {
Raster::from_fn(128, 128, |x, y| [(x * 2) as u8, (y * 2) as u8, (255 - x - y / 2) as u8, 255])
}
fn palette(img: &Raster, palette: Palette, colors: u32, color_detail: f64) -> Vec<[u8; 3]> {
let p = TraceParams { mode: Mode::Color, palette, colors, color_detail, noise: 4, ..TraceParams::default() };
trace(img, &p).palette
}
#[test]
fn palettes_parse_from_their_ids_and_names() {
for (id, want) in [
("limited", Palette::Limited),
("fullTone", Palette::FullTone),
("Automatic", Palette::Automatic),
("documentLibrary", Palette::DocumentLibrary),
("library", Palette::DocumentLibrary),
] {
let p: TraceParams = serde_json::from_value(serde_json::json!({ "palette": id })).unwrap();
assert_eq!(p.palette, want, "{id}");
}
assert_eq!(TraceParams::default().palette, Palette::Limited);
// The library's colours are looked up at every trace, not saved with the settings.
let v = serde_json::to_value(TraceParams { swatches: vec![[1, 2, 3]], ..TraceParams::default() }).unwrap();
assert!(v.get("swatches").is_none() && v["library"] == "document" && v["colorDetail"] == 50.0, "{v}");
}
#[test]
fn full_tone_follows_the_tones_the_image_has() {
// Flat art keeps its few colours (and the tones of its blended edges); a gradient gets many,
// more with more detail.
let flat = palette(&flat_quads(), Palette::FullTone, 6, 50.0);
assert!((4..=10).contains(&flat.len()), "flat art: {flat:?}");
let few = palette(&gradient(), Palette::FullTone, 6, 10.0).len();
let many = palette(&gradient(), Palette::FullTone, 6, 90.0).len();
assert!(few > 6 && many > few && many <= FULL_TONE_MAX, "{few} then {many} colours");
}
#[test]
fn automatic_traces_flat_art_with_its_colours_and_photos_in_full_tone() {
let flat = palette(&flat_quads(), Palette::Automatic, 2, 50.0);
assert_eq!(flat.len(), 4, "the four flat colours, whatever `colors` says: {flat:?}");
assert!(flat.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60), "red is one of them: {flat:?}");
let tone = palette(&gradient(), Palette::Automatic, 2, 50.0);
assert_eq!(tone.len(), palette(&gradient(), Palette::FullTone, 2, 50.0).len(), "continuous tone: as Full Tone");
}
#[test]
fn document_library_uses_the_most_used_swatches_exactly() {
let swatches = vec![[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 0, 0], [255, 255, 255]];
let p = |colors| TraceParams {
mode: Mode::Color,
palette: Palette::DocumentLibrary,
colors,
swatches: swatches.clone(),
noise: 4,
..TraceParams::default()
};
let hard = Raster::from_fn(100, 100, |x, y| match (x < 50, y < 50) {
(true, true) => [220, 30, 30, 255],
(false, true) => [30, 200, 40, 255],
(true, false) => [30, 40, 210, 255],
(false, false) => [240, 220, 20, 255],
});
let res = trace(&hard, &p(6));
let mut got = res.palette.clone();
got.sort_unstable();
assert_eq!(got, [[0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0]], "the nearest swatch of each colour, as it is");
assert!(res.paths.iter().all(|t| swatches.contains(&t.color)));
// At most `colors` of them: the ones the most pixels are nearest to.
let two = trace(&hard, &p(2)).palette;
assert!(two.len() <= 2 && two.iter().all(|c| swatches.contains(c)), "{two:?}");
// Without library colours, it traces as Limited.
let none = TraceParams { swatches: vec![], ..p(4) };
assert_eq!(trace(&hard, &none).palette.len(), 4);
}
fn strokes(fills: bool) -> TraceParams {
TraceParams { strokes: true, fills, stroke_width: 6.0, noise: 4, ..bw() }
}
/// Endpoints (first and last anchor) of each open subpath of the stroked paths.
fn stroked(res: &TraceResult) -> Vec<(&TracedPath, &vectorcraft_geom::SubPath)> {
res.paths.iter().filter(|p| p.stroke.is_some()).flat_map(|p| p.path.subpaths.iter().map(move |s| (p, s))).collect()
}
#[test]
fn a_thin_line_traces_as_one_stroked_centre_line() {
// A 3 px wide diagonal line from (20, 20) to (180, 100).
let img = Raster::from_fn(200, 120, |x, y| {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let t = (((px - 20.0) * 160.0 + (py - 20.0) * 80.0) / (160.0f64.powi(2) + 80.0f64.powi(2))).clamp(0.0, 1.0);
let (cx, cy) = (20.0 + 160.0 * t, 20.0 + 80.0 * t);
if (px - cx).hypot(py - cy) <= 1.5 { BLACK } else { WHITE }
});
let res = trace(&img, &strokes(true));
assert_eq!(res.paths.len(), 1, "{:?}", res.paths.iter().map(|p| p.stroke).collect::<Vec<_>>());
let p = &res.paths[0];
let width = p.stroke.expect("stroked");
assert!((2.0..=4.0).contains(&width), "width {width}");
assert_eq!(p.path.subpaths.len(), 1);
let sp = &p.path.subpaths[0];
assert!(!sp.closed);
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
let (a, b) = if a.x < b.x { (a, b) } else { (b, a) };
assert!(a.distance(Point::new(20.0, 20.0)) < 3.0 && b.distance(Point::new(180.0, 100.0)) < 3.0, "{a:?} … {b:?}");
// A straight line: a few anchors, all on it.
assert!(sp.anchors.len() <= 4, "{} anchors", sp.anchors.len());
// Without Create Strokes, the same line is a filled outline.
let filled = trace(&img, &bw());
assert!(filled.paths.iter().all(|p| p.stroke.is_none()) && filled.paths[0].path.subpaths[0].closed);
}
#[test]
fn a_thin_ring_traces_as_a_closed_centre_line() {
let img = Raster::from_fn(120, 120, |x, y| {
let d = (x as f64 + 0.5 - 60.0).hypot(y as f64 + 0.5 - 60.0);
if (d - 40.0).abs() <= 1.5 { BLACK } else { WHITE }
});
let res = trace(&img, &strokes(true));
let lines = stroked(&res);
assert_eq!(lines.len(), 1, "one ring");
let sp = lines[0].1;
assert!(sp.closed);
let len = PathData::single(sp.clone()).length();
let want = std::f64::consts::TAU * 40.0;
assert!((len - want).abs() / want < 0.05, "length {len} vs {want}");
}
#[test]
fn wide_areas_stay_filled_and_lines_cross_at_junctions() {
// A disc (r = 20) and, apart from it, a plus sign of 3 px wide bars.
let img = Raster::from_fn(200, 100, |x, y| {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let disc = (px - 50.0).hypot(py - 50.0) <= 20.0;
let plus = ((px - 150.0).abs() <= 1.5 && (20.0..=80.0).contains(&py)) || ((py - 50.0).abs() <= 1.5 && (120.0..=180.0).contains(&px));
if disc || plus { BLACK } else { WHITE }
});
let res = trace(&img, &strokes(true));
let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect();
assert_eq!(fills.len(), 1, "the disc");
let a = net_area(&fills[0].path);
assert!((a - std::f64::consts::PI * 400.0).abs() < 60.0, "disc area {a}");
let lines = stroked(&res);
assert!((2..=4).contains(&lines.len()), "{} centre lines", lines.len());
// The centre lines stop about half a width short of each end of the bars (their round caps
// cover it).
let total: f64 = lines.iter().map(|(_, s)| PathData::single((*s).clone()).length()).sum();
assert!((106.0..=120.0).contains(&total), "both bars' length: {total}");
let ends: Vec<Point> = lines.iter().flat_map(|(_, s)| [s.anchors[0].p, s.anchors[s.anchors.len() - 1].p]).collect();
for want in [Point::new(150.0, 20.0), Point::new(150.0, 80.0), Point::new(120.0, 50.0), Point::new(180.0, 50.0)] {
assert!(ends.iter().any(|e| e.distance(want) < 4.0), "a line ends near {want:?}: {ends:?}");
}
// Strokes only: the disc is outlined.
let res = trace(&img, &strokes(false));
assert!(res.paths.iter().all(|p| p.stroke.is_some()));
assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline");
}
#[test]
fn create_settings_parse_with_defaults() {
let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap();
assert!(p.fills && p.strokes && p.stroke_width == 4.0);
let d = TraceParams::default();
assert!(d.fills && !d.strokes && d.stroke_width == 10.0);
}
#[test]
fn create_strokes_survives_noise_and_tiny_images() {
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let noise: Vec<u8> = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect();
let noise = Raster::new(120, 120, noise);
for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] {
for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] {
for noise in [0, 1, 4] {
let p = TraceParams { strokes: true, fills, stroke_width: width, noise, ..TraceParams::default() };
for t in trace(&img, &p).paths {
assert!(t.stroke.is_none_or(|w| w.is_finite() && w >= 1.0), "{:?}", t.stroke);
}
}
}
}
}

View File

@ -284,6 +284,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// pattern tiles stay smooth), as in Illustrator.
anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None },
progressive_placed: true,
trace_views: true,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.

View File

@ -441,17 +441,8 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(Value, Strin
/// The collapsible Advanced Options: Color Mode, Raster Effects and Preview Mode.
fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) {
let t = Tokens::get(ui.ctx());
let open = d.bool("advanced");
let resp = ui
.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, r, t.icon);
ui.label(egui::RichText::new(tl!("Advanced Options")).color(t.text));
})
.response
.interact(egui::Sense::click());
if resp.clicked() {
if widgets::section_toggle(ui, tl!("Advanced Options"), open) {
d.fields.insert("advanced".into(), json!(!open));
}
if !open {

View File

@ -1152,6 +1152,11 @@
Ignore Ignorar
Ignore Artboards (all the art as one page) Ignorar mesas de trabajo (toda la ilustración como una página)
Ignore White Ignorar blanco
Palette: Paleta:
Limited Limitada
Full Tone Tono completo
Document Library Biblioteca del documento
Library: Biblioteca:
Image Imagen
Image Info Información de imagen
Image Map: Mapa de imagen:
@ -1566,6 +1571,9 @@
No text selected No hay texto seleccionado
No Thumbnails Sin miniaturas
Noise Ruido
Create: Crear:
Fills Rellenos
Strokes Trazos
Nonbreaking Hyphen Guion de no separación
Nonbreaking Space Espacio de no separación
None Ninguno
@ -2487,6 +2495,13 @@
Smooth Color Color suavizado
Smooth Tool Herramienta Suavizar
Snap Curves To Lines Ajustar curvas a líneas
View: Vista:
Tracing Result Resultado de calco
Tracing Result with Outlines Resultado de calco con contornos
Outlines Contornos
Outlines with Source Image Contornos con imagen de origen
Source Image Imagen de origen
Image Trace View Vista del calco de imagen
Snap Options Opciones de ajuste
Snap to Glyph Ajustar a glifo
Snap to Grid Ajustar a cuadrícula

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1151,6 +1151,11 @@
Ignore Ignorer
Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page)
Ignore White Ignorer le blanc
Palette: Palette :
Limited Limitée
Full Tone Tons continus
Document Library Bibliothèque du document
Library: Bibliothèque :
Image Image
Image Info Informations sur l’image
Image Map: Image cliquable :
@ -1563,6 +1568,9 @@
No text selected Aucun texte sélectionné
No Thumbnails Sans vignettes
Noise Bruit
Create: Créer :
Fills Fonds
Strokes Contours
Nonbreaking Hyphen Trait d’union insécable
Nonbreaking Space Espace insécable
None Aucun
@ -2484,6 +2492,13 @@
Smooth Color Couleur lissée
Smooth Tool Outil Arrondi
Snap Curves To Lines Aimanter les courbes aux lignes
View: Affichage :
Tracing Result Résultat de la vectorisation
Tracing Result with Outlines Résultat de la vectorisation avec contours
Outlines Contours
Outlines with Source Image Contours avec l’image source
Source Image Image source
Image Trace View Affichage de la vectorisation de l’image
Snap Options Options de magnétisme
Snap to Glyph Aimanter au glyphe
Snap to Grid Aimanter à la grille

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1151,6 +1151,11 @@
Ignore Ignora
Ignore Artboards (all the art as one page) Ignora tavole da disegno (tutta la grafica come una sola pagina)
Ignore White Ignora bianco
Palette: Palette:
Limited Limitata
Full Tone Tono continuo
Document Library Libreria del documento
Library: Libreria:
Image Immagine
Image Info Informazioni immagine
Image Map: Mappa immagine:
@ -1566,6 +1571,9 @@
No text selected Nessun testo selezionato
No Thumbnails Nessuna miniatura
Noise Disturbo
Create: Crea:
Fills Riempimenti
Strokes Tracce
Nonbreaking Hyphen Trattino indivisibile
Nonbreaking Space Spazio indivisibile
None Nessuno
@ -2487,6 +2495,13 @@
Smooth Color Colore graduale
Smooth Tool Strumento Smussa
Snap Curves To Lines Aggancia curve a linee
View: Vista:
Tracing Result Risultato ricalco
Tracing Result with Outlines Risultato ricalco con contorni
Outlines Contorni
Outlines with Source Image Contorni con immagine di origine
Source Image Immagine di origine
Image Trace View Vista del ricalco immagine
Snap Options Opzioni di aggancio
Snap to Glyph Aggancia a glifo
Snap to Grid Aggancia alla griglia

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1410,6 +1410,11 @@
Ignore 無視
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
Ignore White 白を無視
Palette: パレット:
Limited 限定
Full Tone フルトーン
Document Library ドキュメントライブラリ
Library: ライブラリ:
Image Info 画像情報
Image Map: イメージマップ:
Image Size イメージサイズ
@ -1696,6 +1701,9 @@
No symbols in this document このドキュメントにシンボルはありません
No text selected テキストが選択されていません
Noise ノイズ
Create: 作成:
Fills 塗り
Strokes 線
None なし
None. なし。
Normal 通常
@ -2314,6 +2322,13 @@
Smooth スムーズ
Smooth Tool スムーズツール
Snap Curves To Lines 曲線を直線にスナップ
View: 表示:
Tracing Result トレース結果
Tracing Result with Outlines トレース結果(アウトライン付き)
Outlines アウトライン
Outlines with Source Image アウトライン(元画像付き)
Source Image 元画像
Image Trace View 画像トレースの表示
Snap Options スナップオプション
Snapping Tolerance スナップ許容値
Soft Light ソフトライト

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1149,6 +1149,11 @@
Ignore Игнорировать
Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница)
Ignore White Игнорировать белый
Palette: Палитра:
Limited Ограниченная
Full Tone Полутоновая
Document Library Библиотека документа
Library: Библиотека:
Image Изображение
Image Info Сведения об изображении
Image Map: Карта изображения:
@ -1562,6 +1567,9 @@
No text selected Текст не выделен
No Thumbnails Без миниатюр
Noise Шум
Create: Создать:
Fills Заливки
Strokes Обводки
Nonbreaking Hyphen Неразрывный дефис
Nonbreaking Space Неразрывный пробел
None Нет
@ -2483,6 +2491,13 @@
Smooth Color Сглаженный цвет
Smooth Tool Инструмент «Сглаживание»
Snap Curves To Lines Привязывать кривые к линиям
View: Просмотр:
Tracing Result Результат трассировки
Tracing Result with Outlines Результат трассировки с контурами
Outlines Контуры
Outlines with Source Image Контуры с исходным изображением
Source Image Исходное изображение
Image Trace View Просмотр трассировки изображения
Snap Options Параметры привязки
Snap to Glyph Привязать к глифу
Snap to Grid Привязать к сетке

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1140,6 +1140,11 @@
Ignore 忽略
Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页)
Ignore White 忽略白色
Palette: 调板:
Limited 有限
Full Tone 全色调
Document Library 文档库
Library: 库:
Image 图像
Image Info 图像信息
Image Map: 图像映射:
@ -1541,6 +1546,9 @@
No text selected 未选择文字
No Thumbnails 无缩略图
Noise 杂色
Create: 创建:
Fills 填色
Strokes 描边
Nonbreaking Hyphen 不断行连字号
Nonbreaking Space 不断行空格
None 无
@ -2424,6 +2432,13 @@
Smooth Color 平滑颜色
Smooth Tool 平滑工具
Snap Curves To Lines 将曲线对齐到直线
View: 视图:
Tracing Result 描摹结果
Tracing Result with Outlines 描摹结果(带轮廓)
Outlines 轮廓
Outlines with Source Image 轮廓(带源图像)
Source Image 源图像
Image Trace View 图像描摹视图
Snap Options 对齐选项
Snap to Glyph 靠齐字符
Snap to Grid 对齐网格

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1169,6 +1169,11 @@
Ignore 略過
Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面)
Ignore White 忽略白色
Palette: 調色盤:
Limited 有限
Full Tone 全色調
Document Library 文件資料庫
Library: 資料庫:
Image 影像
Image Info 影像資訊
Image Map: 影像地圖:
@ -1583,6 +1588,9 @@
No text selected 未選取文字
No Thumbnails 無縮圖
Noise 雜訊
Create: 建立:
Fills 填色
Strokes 筆畫
Nonbreaking Hyphen 不斷行連字號
Nonbreaking Space 不斷行空格
None 無
@ -2507,6 +2515,13 @@
Smooth Color 平滑顏色
Smooth Tool 平滑工具
Snap Curves To Lines 將曲線靠齊線條
View: 檢視:
Tracing Result 描圖結果
Tracing Result with Outlines 描圖結果(含外框)
Outlines 外框
Outlines with Source Image 外框(含來源影像)
Source Image 來源影像
Image Trace View 影像描圖檢視
Snap Options 靠齊選項
Snap to Glyph 靠齊字符
Snap to Grid 靠齊格點

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1,22 +1,57 @@
//! Image Trace panel: preset, mode, threshold or colour count, and the advanced fidelity options.
//! With an Image Trace object selected, changing a setting re-traces it (one undo step per change;
//! sliders apply when released). With an image selected, Trace makes a new Image Trace object.
//! Image Trace panel: preset, view, mode, palette (Color), threshold or colour count, and the
//! Advanced section (open or closed as it was last left): fidelity, method, and Create: Fills /
//! Strokes with the widest line stroked. With an Image Trace object selected, changing a setting
//! re-traces it (one undo step per change; sliders apply when released) and changing the view
//! redraws it without tracing again. With an image selected, Trace makes a new Image Trace object.
use egui::Ui;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Node, NodeKind, TraceView};
use vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES;
use super::{first_selected, pstate, set_pstate};
use super::{first_selected, library_panel, pstate, set_pstate, swatches};
use crate::VectorcraftApp;
use crate::widgets::{self, menu_item};
const MODES: [(&str, &str); 3] = [("blackAndWhite", "Black and White"), ("grayscale", "Grayscale"), ("color", "Color")];
/// Panel state: the preset name, the current parameters and the last result's counts.
/// Color mode's palettes (`palette` param ids), in panel order.
const PALETTES: [&str; 4] = ["limited", "fullTone", "automatic", "documentLibrary"];
/// Palette `id`, in the UI language.
fn palette_label(id: &str) -> &'static str {
match id {
"fullTone" => tl!("Full Tone"),
"automatic" => tl!("Automatic"),
"documentLibrary" => tl!("Document Library"),
_ => tl!("Limited"),
}
}
/// The name of Document Library's library `key` (the document's swatches or a swatch library); a
/// library that is gone shows its key.
fn library_label(app: &VectorcraftApp, key: &str) -> String {
match swatches::limit_name(app, key) {
Some(_) if key == DOCUMENT_SWATCHES => tl!(swatches::DOCUMENT_SWATCHES).to_string(),
Some(name) => library_panel::library_name(key, &name).to_string(),
None => key.to_string(),
}
}
/// Document Library's choices: the document's swatches, then the colour libraries (`current`
/// checked) → the key chosen.
fn library_list(app: &VectorcraftApp, ui: &mut Ui, current: &str) -> Option<String> {
let document = menu_item(ui, swatches::DOCUMENT_SWATCHES, true, current == DOCUMENT_SWATCHES).then(|| DOCUMENT_SWATCHES.to_string());
ui.separator();
library_panel::library_items(ui, &swatches::colour_libraries(app), Some(current)).or(document)
}
/// Panel state: the preset name, the current parameters, the view and the last result's counts.
#[derive(Clone, Default)]
struct TraceUi {
preset: String,
params: Value,
view: TraceView,
info: Option<(u64, u64, u64)>,
/// The selected Image Trace object's stored settings last adopted (so edits in progress
/// aren't overwritten until the object changes).
@ -44,12 +79,44 @@ fn target(app: &VectorcraftApp) -> (bool, bool, Option<Value>) {
(is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.trace.map(|t| *t))
}
/// The preset of the one selected Image Trace object ("Custom" once its settings were changed).
pub(crate) fn selected_preset(app: &VectorcraftApp) -> Option<String> {
/// The one selected Image Trace object's preset ("Custom" once its settings were changed) and view.
pub(crate) fn selected_trace(app: &VectorcraftApp) -> Option<(String, TraceView)> {
let st = app.session.active()?;
let [id] = st.selection.objects[..] else { return None };
let n = st.doc.node(id).filter(|n| is_trace(n))?;
Some(n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string())
Some((n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view()))
}
/// Image Trace › View, in the UI language.
fn view_label(v: TraceView) -> &'static str {
match v {
TraceView::Result => tl!("Tracing Result"),
TraceView::ResultWithOutlines => tl!("Tracing Result with Outlines"),
TraceView::Outlines => tl!("Outlines"),
TraceView::OutlinesWithSource => tl!("Outlines with Source Image"),
TraceView::Source => tl!("Source Image"),
}
}
/// The View dropdown showing `current` → the view chosen.
fn view_dropdown(ui: &mut Ui, current: TraceView, width: f32) -> Option<TraceView> {
let labels = TraceView::ALL.map(view_label);
widgets::dropdown_names(ui, "trace-view", view_label(current), &labels, width).and_then(|i| TraceView::ALL.get(i).copied())
}
/// Show the selected Image Trace objects with `view` (no new trace).
fn set_view(app: &mut VectorcraftApp, view: TraceView) {
if let Err(e) = app.run("imageTrace.setView", json!({ "view": view.id() })) {
app.ui.status = e;
}
}
/// The selected Image Trace object's View row (Control bar, Properties).
pub(crate) fn view_row(app: &mut VectorcraftApp, ui: &mut Ui, current: TraceView, width: f32) {
widgets::dim_label(ui, tl!("View:"));
if let Some(v) = view_dropdown(ui, current, width) {
set_view(app, v);
}
}
/// Trace the selected image, or trace the selected Image Trace object again, with built-in `preset`.
@ -94,12 +161,13 @@ pub(crate) fn preset_dropdown(app: &mut VectorcraftApp, ui: &mut Ui, current: &s
}
}
/// The Control bar for one selected Image Trace object: its preset, the Image Trace panel and
/// Expand. Whether it is one.
/// The Control bar for one selected Image Trace object: its preset, view, the Image Trace panel
/// and Expand. Whether it is one.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
let Some(preset) = selected_preset(app) else { return false };
let Some((preset, view)) = selected_trace(app) else { return false };
widgets::dim_label(ui, tl!("Preset:"));
preset_dropdown(app, ui, &preset, 150.0);
view_row(app, ui, view, 190.0);
if widgets::icon_button(ui, "image", tl!("Image Trace"), false, 24.0).clicked() {
app.ui.open_panel = Some("imageTrace".into());
}
@ -112,7 +180,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
fn trace(app: &mut VectorcraftApp, st: &mut TraceUi) {
let preset = if st.preset == "Custom" { "Default" } else { st.preset.as_str() };
match app.run("imageTrace.make", json!({ "preset": preset, "params": st.params })) {
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))),
Err(e) => app.ui.status = e,
}
@ -143,6 +211,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if let Some(t) = stored.filter(|t| *t != st.synced) {
st.preset = t["preset"].as_str().unwrap_or("Custom").to_string();
st.params = t["params"].clone();
st.view = TraceView::of(&t);
st.synced = t;
}
let mut retrace = false;
@ -156,6 +225,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
retrace = true;
}
});
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("View:"));
if let Some(v) = view_dropdown(ui, st.view, 170.0) {
st.view = v;
if is_trace {
set_view(app, v);
}
}
});
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Mode:"));
let mode = st.params["mode"].as_str().unwrap_or("blackAndWhite").to_string();
@ -167,23 +245,49 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
retrace = true;
}
});
let bw = st.params["mode"].as_str() == Some("blackAndWhite");
let (key, label, range) = if bw {
("threshold", tl!("Threshold"), 0.0..=255.0)
} else {
("colors", if st.params["mode"] == "grayscale" { tl!("Grays") } else { tl!("Colors") }, 2.0..=256.0)
let mode = st.params["mode"].as_str().unwrap_or("blackAndWhite").to_string();
let palette = st.params["palette"].as_str().unwrap_or("limited").to_string();
if mode == "color" {
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Palette:"));
let labels = PALETTES.map(palette_label);
if let Some(p) = widgets::dropdown_names(ui, "it-palette", palette_label(&palette), &labels, 170.0).and_then(|i| PALETTES.get(i)) {
st.params["palette"] = json!(p);
st.preset = "Custom".into();
retrace = true;
}
});
if palette == "documentLibrary" {
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Library:"));
let key = st.params["library"].as_str().unwrap_or(DOCUMENT_SWATCHES).to_string();
if let Some(k) = widgets::combo(ui, "it-library", &library_label(app, &key), 170.0, false, |ui| library_list(app, ui, &key)) {
st.params["library"] = json!(k);
st.preset = "Custom".into();
retrace = true;
}
});
}
}
// Full Tone and Automatic: how many colours (Less … More); else a count or the threshold.
let (key, label, range, suffix) = match mode.as_str() {
"blackAndWhite" => ("threshold", tl!("Threshold"), 0.0..=255.0, ""),
"grayscale" => ("colors", tl!("Grays"), 2.0..=256.0, ""),
_ if matches!(palette.as_str(), "fullTone" | "automatic") => ("colorDetail", tl!("Colors"), 0.0..=100.0, "%"),
_ => ("colors", tl!("Colors"), 2.0..=256.0, ""),
};
let mut v = st.params[key].as_f64().unwrap_or(0.0);
let (changed, release) = slider(ui, label, &mut v, range, "");
let (changed, release) = slider(ui, label, &mut v, range, suffix);
if changed {
st.params[key] = json!(v.round() as u64);
st.params[key] = if key == "colorDetail" { json!(v.round()) } else { json!(v.round() as u64) };
st.preset = "Custom".into();
}
retrace |= release;
let open: bool = !pstate::<bool>(ui.ctx(), "it-advanced-closed");
if ui.add(egui::Button::new(format!("{} {}", if open { "▾" } else { "▸" }, tl!("Advanced"))).frame(false)).clicked() {
set_pstate(ui.ctx(), "it-advanced-closed", open);
ui.add_space(4.0);
let open = app.ui.image_trace_advanced;
if widgets::section_toggle(ui, tl!("Advanced"), open) {
app.ui.image_trace_advanced = !open;
}
if open {
for (key, label, range, suffix) in
@ -207,6 +311,27 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
});
// Create: Fills and/or Strokes (one of them stays on), and the widest line stroked.
let create = |p: &Value, key: &str| p[key].as_bool().unwrap_or(key == "fills");
let strokes = create(&st.params, "strokes");
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Create:"));
for (key, other, label) in [("fills", "strokes", tl!("Fills")), ("strokes", "fills", tl!("Strokes"))] {
let on = create(&st.params, key);
if widgets::check(ui, label, on, !on || create(&st.params, other)) {
st.params[key] = json!(!on);
st.preset = "Custom".into();
retrace = true;
}
}
});
let mut v = st.params["strokeWidth"].as_f64().unwrap_or(10.0);
let (changed, release) = ui.add_enabled_ui(strokes, |ui| slider(ui, tl!("Stroke"), &mut v, 1.0..=100.0, " px")).inner;
if changed {
st.params["strokeWidth"] = json!(v.round());
st.preset = "Custom".into();
}
retrace |= release;
for (key, label) in [("snapCurvesToLines", tl!("Snap Curves To Lines")), ("ignoreWhite", tl!("Ignore White"))] {
let on = st.params[key].as_bool().unwrap_or(false);
if widgets::check(ui, label, on, true) {
@ -258,6 +383,119 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, PointerButton, Pos2, Rect, vec2};
/// One headless frame of the panel (and its menu) with `events`: the texts drawn, with where.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
/// Click the panel's `label` (the first one drawn).
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
let texts = frame(app, ctx, vec![]);
let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}"));
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, vec![Event::PointerMoved(at), press(true)]);
frame(app, ctx, vec![press(false)]);
}
fn traced_app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [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.run("imageTrace.make", json!({"preset": "Default"})).unwrap();
app
}
#[test]
fn advanced_is_a_section_that_stays_as_it_was_left() {
let mut app = traced_app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
let texts = frame(&mut app, &ctx, vec![]);
assert!(app.ui.image_trace_advanced && has(&texts, "Advanced") && has(&texts, "Corners"), "open at first: {texts:?}");
click(&mut app, &ctx, "Advanced");
assert!(!app.ui.image_trace_advanced, "the header closes it");
assert!(!has(&frame(&mut app, &ctx, vec![]), "Corners"));
// Remembered with the UI preferences, across launches.
let saved: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
assert!(!saved.image_trace_advanced);
let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap();
assert!(old.image_trace_advanced, "open for preferences saved before the section existed");
click(&mut app, &ctx, "Advanced");
assert!(app.ui.image_trace_advanced && has(&frame(&mut app, &ctx, vec![]), "Corners"));
}
#[test]
fn the_view_dropdown_shows_and_sets_the_objects_view() {
let mut app = traced_app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "View:") && texts.iter().any(|(t, _)| t == "Tracing Result"), "{texts:?}");
let steps = app.session.active().unwrap().history.undo.len();
click(&mut app, &ctx, "Tracing Result");
click(&mut app, &ctx, "Source Image");
assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Source));
assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step, no new trace");
// Another object's view is shown when it is selected.
app.run("imageTrace.setView", json!({"view": "outlines"})).unwrap();
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Outlines"));
}
#[test]
fn the_palette_shows_in_color_mode_and_traces_again() {
let mut app = traced_app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
let palette = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["palette"].clone());
assert!(!has(&frame(&mut app, &ctx, vec![]), "Palette:"), "Black and White has no palette");
app.run("imageTrace.make", json!({"preset": "6 Colors"})).unwrap();
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Palette:") && has(&texts, "Limited") && !has(&texts, "Library:"), "{texts:?}");
// Document Library traces again with the document's swatches, and names them.
click(&mut app, &ctx, "Limited");
click(&mut app, &ctx, "Document Library");
assert_eq!(palette(&app), Some(json!("documentLibrary")));
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Library:") && has(&texts, "Document Swatches"), "{texts:?}");
// Full Tone's Colors is how many, Less (0%) … More (100%).
click(&mut app, &ctx, "Document Library");
click(&mut app, &ctx, "Full Tone");
assert_eq!(palette(&app), Some(json!("fullTone")));
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "50") && !has(&texts, "Library:"), "{texts:?}");
}
#[test]
fn create_strokes_traces_again_and_keeps_one_kind_on() {
let mut app = traced_app();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let create = |app: &VectorcraftApp| target(app).2.map(|t| (t["params"]["fills"].clone(), t["params"]["strokes"].clone()));
let texts = frame(&mut app, &ctx, vec![]);
for s in ["Create:", "Fills", "Strokes", "Stroke", "10"] {
assert!(texts.iter().any(|(t, _)| t == s), "{s}: {texts:?}");
}
// Fills alone can't be turned off.
click(&mut app, &ctx, "Fills");
assert_eq!(create(&app), Some((json!(true), json!(false))));
click(&mut app, &ctx, "Strokes");
assert_eq!(create(&app), Some((json!(true), json!(true))), "traced again with strokes");
click(&mut app, &ctx, "Fills");
assert_eq!(create(&app), Some((json!(false), json!(true))));
click(&mut app, &ctx, "Strokes");
assert_eq!(create(&app), Some((json!(false), json!(true))), "Strokes alone stays on");
}
#[test]
fn panel_and_menu_draw_headless() {
@ -266,6 +504,7 @@ mod tests {
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
for _ in 0..2 {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(&mut app, ui);
menu(&mut app, ui);

View File

@ -44,8 +44,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
image_section(app, ui);
divider(ui);
}
if let Some(preset) = crate::panels::image_trace::selected_preset(app) {
trace_section(app, ui, &preset);
if let Some((preset, view)) = crate::panels::image_trace::selected_trace(app) {
trace_section(app, ui, &preset, view);
divider(ui);
}
if matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Text(_))) {
@ -162,13 +162,14 @@ fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) {
}
/// The one selected Image Trace object: its preset (choosing another traces it again with that
/// one), Expand and Release.
fn trace_section(app: &mut VectorcraftApp, ui: &mut Ui, preset: &str) {
/// one), view, Expand and Release.
fn trace_section(app: &mut VectorcraftApp, ui: &mut Ui, preset: &str, view: vectorcraft_doc::TraceView) {
section_header(ui, tl!("Image Trace"));
ui.horizontal(|ui| {
dim_label(ui, tl!("Preset:"));
crate::panels::image_trace::preset_dropdown(app, ui, preset, ui.available_width());
});
ui.horizontal(|ui| crate::panels::image_trace::view_row(app, ui, view, ui.available_width()));
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
for (label, id) in [(tl!("Expand"), "imageTrace.expand"), (tl!("Release"), "imageTrace.release")] {

View File

@ -365,6 +365,9 @@ pub struct UiState {
/// Layers panel › Panel Options… (row size, thumbnails, Show Layers Only).
#[serde(default)]
pub layers_panel: crate::panels::layers::PanelOptions,
/// The Image Trace panel's Advanced section is open (as it was last left).
#[serde(default = "yes")]
pub image_trace_advanced: bool,
/// The desktop window's size, position and maximized state, saved when the app quits and
/// restored at the next launch (the desktop host reads and writes it; none on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -518,6 +521,7 @@ impl Default for UiState {
dxf_import: Value::Null,
home: None,
layers_panel: Default::default(),
image_trace_advanced: true,
window: None,
}
}

View File

@ -308,7 +308,8 @@ fn click_label(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
#[test]
fn the_control_bar_and_properties_trace_a_selected_image() {
use crate::panels::image_trace::{TRACE_BUTTON_W, selected_preset};
use crate::panels::image_trace::{TRACE_BUTTON_W, selected_trace};
let selected_preset = |app: &VectorcraftApp| selected_trace(app).map(|t| t.0);
use crate::tests_removeanchors::{at, click_control, control_frame, has, properties_frame};
let mut app = app();
let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0));
@ -332,19 +333,27 @@ fn the_control_bar_and_properties_trace_a_selected_image() {
app.run("edit.undo", json!({})).unwrap();
click_label(&mut app, &ctx, "Image Trace");
assert_eq!(selected_preset(&app).as_deref(), Some("Default"));
// An Image Trace object: its preset (another traces it again), the panel and Expand.
// An Image Trace object: its preset (another traces it again), view, the panel and Expand.
let bar = control_frame(&mut app, &ctx, vec![]);
for s in ["Image Tracing", "Preset:", "Default", "Expand"] {
for s in ["Image Tracing", "Preset:", "Default", "View:", "Tracing Result", "Expand"] {
assert!(has(&bar, s), "{s}");
}
assert!(has(&properties_frame(&mut app, 260.0), "Release"));
let props = properties_frame(&mut app, 260.0);
assert!(has(&props, "Release") && has(&props, "View:"));
// Another view redraws it without tracing again.
let traced = app.session.active().unwrap().selection.objects.clone();
click_control(&mut app, &ctx, at(&bar, "Tracing Result"));
click_label(&mut app, &ctx, "Outlines with Source Image");
assert_eq!(selected_trace(&app).map(|t| t.1), Some(vectorcraft_doc::TraceView::OutlinesWithSource));
assert_eq!(app.session.active().unwrap().selection.objects, traced, "the same object");
let bar = control_frame(&mut app, &ctx, vec![]);
click_control(&mut app, &ctx, at(&bar, "Default"));
click_label(&mut app, &ctx, "3 Colors");
assert_eq!(selected_preset(&app).as_deref(), Some("3 Colors"));
click_label(&mut app, &ctx, "Expand");
let st = app.session.active().unwrap();
let g = st.doc.node(st.selection.objects[0]).unwrap();
assert!(selected_preset(&app).is_none() && g.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_))), "expanded");
assert!(selected_trace(&app).is_none() && g.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_))), "expanded");
}
#[test]

View File

@ -37,7 +37,7 @@ fn labels(items: &[Item]) -> Vec<&'static str> {
.collect()
}
fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> {
pub(crate) fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> {
fn walk(s: &Shape, v: &mut Vec<(String, Rect)>) {
match s {
Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),

View File

@ -121,6 +121,21 @@ pub fn section_header(ui: &mut Ui, text: &str) {
ui.add_space(2.0);
}
/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`;
/// a click anywhere on it toggles the section. Whether it was clicked.
pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(rect, 3.0, t.hover);
}
let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0));
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon });
ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(text), theme::semibold(12.5), t.text);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, tl!(text)));
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// Full-width 1 px divider with vertical margins.
pub fn divider(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());