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:
commit
147fc113c3
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¶ms).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, ¶ms, 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| {
|
||||
|
|
|
|||
|
|
@ -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 "").
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¢ers, *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, ¶ms.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()];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")] {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()))),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue