Merge #654: Image Trace: Palette (Limited, Full Tone, Automatic, Document Library)

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/buildcmds.rs
#	crates/ui-egui/src/panels/image_trace.rs
This commit is contained in:
bflatastic 2026-10-09 10:14:35 +07:00
commit eea4fe9e58
16 changed files with 467 additions and 42 deletions

File diff suppressed because one or more lines are too long

View File

@ -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}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {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}, 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)
),
@ -667,6 +667,30 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
}
/// 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
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
/// refused with advice instead (#525).
@ -674,7 +698,7 @@ pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let params = trace_params(p)?;
let mut params = trace_params(p)?;
let view = view_param(C, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's, else "Custom".
@ -704,6 +728,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let library = trace_library(s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
@ -731,7 +756,9 @@ 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);
n.appearance = fill_only(Paint::solid(color));
children.push(Arc::new(n));
}
let gid = d.alloc_id();

View File

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

View File

@ -669,6 +669,49 @@ 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_presets_query_and_errors() {
let mut s = session();

View File

@ -295,7 +295,9 @@ 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,
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. 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. imageTrace.setView {view: \"outlinesWithSourceImage\"} draws the traced outlines

View File

@ -4,8 +4,11 @@
//! and classic colour quantisation), not from any existing implementation:
//!
//! 1. **Quantise** the image into a small palette: a luminance threshold (Black and White), 1-D
//! k-means over the luminance histogram (Grayscale), or median cut followed by weighted
//! k-means over a 15-bit colour histogram (Color). Transparent pixels are left out.
//! k-means over the luminance histogram (Grayscale), or, in Color, over a 15-bit colour
//! histogram as the [`Palette`] asks: median cut to a colour count followed by weighted k-means
//! (Limited), median cut until every colour box is tight (Full Tone), one of those two by
//! whether the image is flat art (Automatic), or the most used colours of a swatch library
//! (Document Library). Transparent pixels are left out.
//! 2. **Remove noise**: 4-connected same-colour components smaller than `noise` pixels are merged
//! into their most common neighbouring colour.
//! 3. **Follow boundaries**: for each colour layer, the pixel-crack edges between inside and
@ -34,7 +37,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 +135,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 +164,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.
@ -160,7 +197,11 @@ 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,

View File

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

View File

@ -348,3 +348,93 @@ 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);
}

View File

@ -1152,6 +1152,11 @@
Ignore Ignorar
Ignore Artboards (all the art as one page) Ignorar mesas de trabajo (toda la ilustración como una página)
Ignore White Ignorar blanco
Palette: Paleta:
Limited Limitada
Full Tone Tono completo
Document Library Biblioteca del documento
Library: Biblioteca:
Image Imagen
Image Info Información de imagen
Image Map: Mapa de imagen:

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

View File

@ -1151,6 +1151,11 @@
Ignore Ignorer
Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page)
Ignore White Ignorer le blanc
Palette: Palette :
Limited Limitée
Full Tone Tons continus
Document Library Bibliothèque du document
Library: Bibliothèque :
Image Image
Image Info Informations sur l’image
Image Map: Image cliquable :

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

View File

@ -1151,6 +1151,11 @@
Ignore Ignora
Ignore Artboards (all the art as one page) Ignora tavole da disegno (tutta la grafica come una sola pagina)
Ignore White Ignora bianco
Palette: Palette:
Limited Limitata
Full Tone Tono continuo
Document Library Libreria del documento
Library: Libreria:
Image Immagine
Image Info Informazioni immagine
Image Map: Mappa immagine:

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

View File

@ -1410,6 +1410,11 @@
Ignore 無視
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
Ignore White 白を無視
Palette: パレット:
Limited 限定
Full Tone フルトーン
Document Library ドキュメントライブラリ
Library: ライブラリ:
Image Info 画像情報
Image Map: イメージマップ:
Image Size イメージサイズ

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

View File

@ -1149,6 +1149,11 @@
Ignore Игнорировать
Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница)
Ignore White Игнорировать белый
Palette: Палитра:
Limited Ограниченная
Full Tone Полутоновая
Document Library Библиотека документа
Library: Библиотека:
Image Изображение
Image Info Сведения об изображении
Image Map: Карта изображения:

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

View File

@ -1140,6 +1140,11 @@
Ignore 忽略
Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页)
Ignore White 忽略白色
Palette: 调板:
Limited 有限
Full Tone 全色调
Document Library 文档库
Library: 库:
Image 图像
Image Info 图像信息
Image Map: 图像映射:

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

View File

@ -1169,6 +1169,11 @@
Ignore 略過
Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面)
Ignore White 忽略白色
Palette: 調色盤:
Limited 有限
Full Tone 全色調
Document Library 文件資料庫
Library: 資料庫:
Image 影像
Image Info 影像資訊
Image Map: 影像地圖:

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

View File

@ -1,19 +1,51 @@
//! Image Trace panel: preset, view, mode, threshold or colour count, and the Advanced section's
//! fidelity options (open or closed as it was last left). 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.
//! Image Trace panel: preset, view, mode, palette (Color), threshold or colour count, and the
//! Advanced section's fidelity options (open or closed as it was last left). 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, 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")];
/// 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 {
@ -213,16 +245,41 @@ 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;
@ -373,6 +430,31 @@ mod tests {
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 panel_and_menu_draw_headless() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());