mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Image Trace: Abutting traces draw seam fills under their paths
An Abutting trace with Fills now holds a "Seam Fills" group under its paths. For each color it has one stroke, 1.5 image pixels wide, along the edges where a filled path of that color meets a filled path drawn after it, in that color. A renderer that anti-aliases each path on its own no longer shows a light hairline along those edges. The traced paths and their shared edges do not change, and edges against the image's border, a transparent area or a color left out get no stroke. - vectorcraft-trace: `seam_fills` finds the segments two filled paths share and strokes them in the lower path's color (one stroke per gradient path). Overlapping, traces without Fills and Flat Logo get none. - Image Trace commands: the group comes under every path (right above the hidden image in a live trace). Expand keeps it, Expand by color leaves it under the color groups, and a trace whose paths have more than half of the anchor limit gets no seam fills. Docs: docs/mcp.md, the params text, docs/target-app-parity.md.
This commit is contained in:
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", 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), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", 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), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\" (abutting with fills, not mode logo: a \"Seam Fills\" group under the paths holds 1.5 px strokes along the edges two filled paths share, in the color of the lower path, so that no light hairline shows along those edges; the paths are not changed), ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
|
||||
@@ -22,6 +22,22 @@ 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 name of the group that holds an Abutting trace's seam fills (see [`tr::TraceResult::seams`]).
|
||||
const SEAM_FILLS_NAME: &str = "Seam Fills";
|
||||
|
||||
/// Whether `n` can be a trace's seam fills group: a group named as one, or a group of stroked paths
|
||||
/// without fills, so that it is still found after it is renamed or its strokes are edited. (Auto
|
||||
/// Grouping groups hold filled paths.)
|
||||
fn is_seam_fills(n: &Node) -> bool {
|
||||
let stroke_only = |m: &Arc<Node>| {
|
||||
matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& m.appearance.fill_paint() == Paint::None
|
||||
&& m.appearance.stroke_paint() != Paint::None
|
||||
};
|
||||
matches!(n.kind, NodeKind::Group { clip: false, .. })
|
||||
&& (n.name.as_deref() == Some(SEAM_FILLS_NAME) || n.children().is_some_and(|c| !c.is_empty() && c.iter().all(stroke_only)))
|
||||
}
|
||||
|
||||
const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor";
|
||||
|
||||
/// The `view` param ([`TraceView`] id), if given.
|
||||
@@ -393,11 +409,15 @@ fn by_color_param(cmd: &str, p: &Value) -> Result<bool> {
|
||||
/// (by the boxes of everything they paint, effects included, within one point); otherwise it
|
||||
/// starts another group of that paint, which keeps the stacking order of those items. Items
|
||||
/// farther apart can still share antialiased pixels at small view or raster-export scales, so
|
||||
/// their edge pixels may change. A group among `items` (Auto Grouping) is refused.
|
||||
/// their edge pixels may change. The seam fills' group, when it is the lowest item, stays as it is,
|
||||
/// under every other group. Any other group among `items` (Auto Grouping) is refused.
|
||||
fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Vec<Arc<Node>>> {
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ops::Bound::{Excluded, Unbounded};
|
||||
use vectorcraft_color::tint_percent;
|
||||
// The seam fills come first, under every path: kept there, they move under nothing.
|
||||
let mut items = items;
|
||||
let seams: Vec<Arc<Node>> = if items.first().is_some_and(|n| is_seam_fills(n)) { items.drain(..1).collect() } else { vec![] };
|
||||
// Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a
|
||||
// gradient, or another paint (its label). The name is apart from it: a swatch may be named as
|
||||
// a color's `#rrggbb` or as "Gradients".
|
||||
@@ -517,14 +537,15 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Ve
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(groups
|
||||
let groups: Vec<Arc<Node>> = groups
|
||||
.into_iter()
|
||||
.map(|run| {
|
||||
let mut g = Node::group(d.alloc_id(), run.members);
|
||||
g.name = Some(run.name);
|
||||
Arc::new(g)
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
Ok(seams.into_iter().chain(groups).collect())
|
||||
}
|
||||
|
||||
/// What a trace depends on: the document it is made in, the object traced (an image, or an Image
|
||||
@@ -608,11 +629,13 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
|
||||
Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library })
|
||||
}
|
||||
|
||||
/// What a trace made of an image: the size in pixels it was decoded at, and the paths.
|
||||
/// What a trace made of an image: the size in pixels it was decoded at, the paths, and their seam
|
||||
/// fills ([`seams_within`]).
|
||||
pub struct Traced {
|
||||
inputs: TraceInputs,
|
||||
raster: (u32, u32),
|
||||
res: tr::TraceResult,
|
||||
seams: Vec<tr::TracedPath>,
|
||||
}
|
||||
|
||||
impl Traced {
|
||||
@@ -636,7 +659,18 @@ fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64
|
||||
tr::TraceError::Cancelled => EngineError::Cancelled,
|
||||
e => EngineError::Other(e.to_string()),
|
||||
})?;
|
||||
Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res })
|
||||
let seams = seams_within(&res, params, MAX_TRACE_ANCHORS);
|
||||
Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res, seams })
|
||||
}
|
||||
|
||||
/// The seam fills of `res`, traced with `params` ([`tr::seam_fills`]), while the paths and the seam
|
||||
/// fills together stay within `max` anchors: the seam fills have at most as many anchors as the
|
||||
/// paths, so a trace whose paths have more than half of `max` gets none.
|
||||
fn seams_within(res: &tr::TraceResult, params: &tr::TraceParams, max: usize) -> Vec<tr::TracedPath> {
|
||||
if res.anchor_count() > max / 2 {
|
||||
return vec![];
|
||||
}
|
||||
tr::seam_fills(&res.paths, params)
|
||||
}
|
||||
|
||||
/// An Image Trace made away from its command, off the UI thread (#3098). [`TraceRun::prepare`]
|
||||
@@ -716,7 +750,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
s.note_journal("savedPreset", recorded);
|
||||
}
|
||||
// A trace handed in for these very inputs ([`TraceRun`]), else one made now.
|
||||
let Traced { raster, res, .. } = match s.trace_hand_off.take() {
|
||||
let Traced { raster, res, seams: seam_fills, .. } = match s.trace_hand_off.take() {
|
||||
Some(traced) if traced.inputs == inputs => traced,
|
||||
_ => trace_now(&inputs, &trace, &|_, _| true)?,
|
||||
};
|
||||
@@ -729,6 +763,13 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
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();
|
||||
// Seam fills: document units per raster pixel along the direction the image is stretched most,
|
||||
// so that a stroke covers its pixels across an edge at any angle.
|
||||
let seam_unit = {
|
||||
let [a, b, c, d, _, _] = xf.as_coeffs();
|
||||
let (t, det) = (a * a + b * b + c * c + d * d, a * d - b * c);
|
||||
((t + (t * t - 4.0 * det * det).max(0.0).sqrt()) / 2.0).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" };
|
||||
// A library color fills with the swatch's displayed color (its model kept), unlinked.
|
||||
@@ -754,8 +795,38 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
hidden.visible = false;
|
||||
children.push(Arc::new(hidden));
|
||||
}
|
||||
let paint_of = |tp: &tr::TracedPath| {
|
||||
let solid =
|
||||
by_color.get(&tp.color).map_or_else(|| Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])), |paint| (*paint).clone());
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
tp.gradient.as_ref().map_or(solid, |g| traced_gradient(g, xf))
|
||||
};
|
||||
// Abutting: the seam fills, in a group of their own under every path.
|
||||
let seams: Vec<Arc<Node>> = seam_fills
|
||||
.iter()
|
||||
.filter_map(|tp| {
|
||||
let width = tp.stroke?;
|
||||
let mut n = shape_node(d, tp.path.transformed(xf), None);
|
||||
n.appearance = Appearance {
|
||||
items: vec![AppearanceItem::Stroke(StrokeLayer {
|
||||
cap: LineCap::Butt,
|
||||
join: LineJoin::Round,
|
||||
..StrokeLayer::new(paint_of(tp), width * seam_unit)
|
||||
})],
|
||||
..Default::default()
|
||||
}
|
||||
.into();
|
||||
Some(Arc::new(n))
|
||||
})
|
||||
.collect();
|
||||
if !seams.is_empty() {
|
||||
let mut g = Node::group(d.alloc_id(), seams);
|
||||
g.name = Some(SEAM_FILLS_NAME.into());
|
||||
children.push(Arc::new(g));
|
||||
}
|
||||
let mut traced = Vec::with_capacity(res.paths.len());
|
||||
for tp in res.paths {
|
||||
let paint = paint_of(&tp);
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
// Shapes: a path the tracer found to be a shape is that live shape.
|
||||
@@ -764,10 +835,6 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
*path = shape.to_path();
|
||||
*live = Some(Box::new(shape));
|
||||
}
|
||||
let solid =
|
||||
by_color.get(&tp.color).map_or_else(|| Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])), |paint| (*paint).clone());
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
let paint = tp.gradient.as_ref().map_or(solid, |g| traced_gradient(g, xf));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round corners, and ends round or square as the tracer found them.
|
||||
Some(width) => Appearance {
|
||||
@@ -1103,9 +1170,28 @@ mod color_group_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod trace_limit_tests {
|
||||
use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings};
|
||||
use super::{MAX_TRACE_ANCHORS, keep_warnings, seams_within, trace_warnings};
|
||||
use vectorcraft_trace as tr;
|
||||
|
||||
#[test]
|
||||
fn seam_fills_count_against_the_anchor_limit() {
|
||||
// Two colors side by side, the edge between them anti-aliased: one seam fill along it.
|
||||
let img = tr::Raster::from_fn(24, 16, |x, _| {
|
||||
let share = (10.4 - f64::from(x)).clamp(0.0, 1.0);
|
||||
let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8;
|
||||
[mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255]
|
||||
});
|
||||
let params = tr::TraceParams { mode: tr::Mode::Color, colors: 2, method: tr::Method::Abutting, ..tr::TraceParams::default() };
|
||||
let res = tr::trace(&img, ¶ms);
|
||||
let all = tr::seam_fills(&res.paths, ¶ms);
|
||||
assert!(!all.is_empty(), "{res:?}");
|
||||
let anchors = res.anchor_count();
|
||||
// Twice the paths' anchors leaves room for any seam fills.
|
||||
assert_eq!(seams_within(&res, ¶ms, 2 * anchors), all);
|
||||
// One anchor short of that: none.
|
||||
assert_eq!(seams_within(&res, ¶ms, 2 * anchors - 1), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_warnings_name_matched_colors_that_produce_no_paths() {
|
||||
let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() };
|
||||
|
||||
@@ -5341,8 +5341,9 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The traced paths of an Image Trace object: its children but the image and the seam fills.
|
||||
fn traced_paths(n: &Node) -> Vec<Arc<Node>> {
|
||||
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect()
|
||||
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_)) && c.name.as_deref() != Some("Seam Fills")).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5961,6 +5962,73 @@ fn image_trace_release_and_expand() {
|
||||
assert!(s.execute("imageTrace.release", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Abutting with Fills: the strokes along the edges the paths share sit in a "Seam Fills" group
|
||||
/// under the paths, in the lower path's color, and stay under them through Expand by color.
|
||||
#[test]
|
||||
fn image_trace_abutting_seam_fills_sit_under_the_paths() {
|
||||
// Red beside blue, the edge between them anti-aliased.
|
||||
let art = vectorcraft_trace::Raster::from_fn(40, 20, |x, _| {
|
||||
let share = (16.4 - f64::from(x)).clamp(0.0, 1.0);
|
||||
let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8;
|
||||
[mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255]
|
||||
});
|
||||
let seams = |n: &Node| n.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Seam Fills")).cloned();
|
||||
let made = |method: &str, cmd: &str, by_color: bool| {
|
||||
let mut s = session();
|
||||
let img = add_image(&mut s, &art, Affine::scale(2.0));
|
||||
let params = json!({"mode": "color", "palette": "limited", "colors": 2, "method": method});
|
||||
let mut p = json!({ "params": params });
|
||||
if by_color {
|
||||
p["byColor"] = json!(true);
|
||||
}
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
(s, img, r)
|
||||
};
|
||||
let (mut s, _, r) = made("abutting", "imageTrace.make", false);
|
||||
assert_eq!(r["paths"], 2, "{r}");
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
let n = node(&s, g);
|
||||
let kids = n.children().unwrap();
|
||||
assert_eq!(kids.len(), 4, "the image, the seam fills and two paths");
|
||||
assert!(matches!(kids[0].kind, NodeKind::Image(_)));
|
||||
let group = seams(&n).unwrap();
|
||||
assert!(Arc::ptr_eq(&group, &kids[1]), "the seam fills come under the paths");
|
||||
let strokes = group.children().unwrap();
|
||||
assert_eq!(strokes.len(), 1);
|
||||
assert_eq!(strokes[0].appearance.fill_paint(), Paint::None);
|
||||
let AppearanceItem::Stroke(layer) = &strokes[0].appearance.items[0] else { panic!("{:?}", strokes[0].appearance) };
|
||||
assert_eq!((layer.cap, layer.join, layer.width), (LineCap::Butt, LineJoin::Round, 3.0), "1.5 image pixels, the image scaled 2×");
|
||||
assert_eq!(layer.paint, kids[2].appearance.fill_paint(), "the lower path's color");
|
||||
// The stroke runs along the edge, from the image's top to its bottom.
|
||||
let bb = outline(&strokes[0]).0.bounds().unwrap();
|
||||
assert!((bb.center().x - 32.8).abs() < 1.0 && bb.y0.abs() < 1e-9 && (bb.y1 - 40.0).abs() < 1e-9, "{bb:?}");
|
||||
// Expand by color keeps the seam fills under the color groups, renamed too; Release gives the
|
||||
// image back.
|
||||
s.execute("layer.setProps", &json!({"id": group.id.0, "name": "Edges"})).unwrap();
|
||||
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
|
||||
let n = node(&s, g);
|
||||
let names: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names.len(), 3, "{names:?}");
|
||||
assert_eq!(names[0], "Edges", "{names:?}");
|
||||
assert_eq!(n.children().unwrap()[0].id, group.id);
|
||||
let (mut s, img, _) = made("abutting", "imageTrace.make", false);
|
||||
assert_eq!(ids(&s.execute("imageTrace.release", &json!({})).unwrap()), vec![img]);
|
||||
// Make and Expand by color too.
|
||||
let (s, _, r) = made("abutting", "imageTrace.makeAndExpand", true);
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Seam Fills"));
|
||||
// Overlapping paths are stacked: no seam fills.
|
||||
let (s, _, r) = made("overlapping", "imageTrace.make", false);
|
||||
assert!(seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).is_none());
|
||||
// An image stretched 3× across and squeezed to half height: 1.5 pixels at the larger scale.
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::scale_non_uniform(3.0, 0.5));
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "method": "abutting"}})).unwrap();
|
||||
let group = seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).unwrap();
|
||||
let AppearanceItem::Stroke(layer) = &group.children().unwrap()[0].appearance.items[0] else { panic!("{group:?}") };
|
||||
assert!((layer.width - 4.5).abs() < 1e-9, "{}", layer.width);
|
||||
}
|
||||
|
||||
/// Expand by color: each color's paths in a group of their own, named by the color, the groups
|
||||
/// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a
|
||||
/// sublayer for each color.
|
||||
@@ -5995,7 +6063,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
};
|
||||
let (flat, fg) = expand(false);
|
||||
let (mut s, g) = expand(true);
|
||||
let paths = node(&flat, fg).children().unwrap().clone();
|
||||
let kids = node(&flat, fg).children().unwrap().clone();
|
||||
// Abutting: the seam fills' group comes first, under every path, with byColor too.
|
||||
let seams = usize::from(method == "abutting");
|
||||
assert!(kids.iter().take(seams).all(|c| c.name.as_deref() == Some("Seam Fills")), "{method}");
|
||||
let paths = kids[seams..].to_vec();
|
||||
// Without byColor (the default) the paths sit in the trace's group, with no group of their own.
|
||||
assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}");
|
||||
let (default, dg) = {
|
||||
@@ -6004,7 +6076,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
};
|
||||
assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given");
|
||||
assert!(*node(&default, dg).children().unwrap() == kids, "{method}: byColor is false when not given");
|
||||
// The colors in the order they first come in, bottom to top.
|
||||
let mut order: Vec<String> = vec![];
|
||||
for p in &paths {
|
||||
@@ -6019,10 +6091,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
// Overlapping fills the ring whole and draws the field inside it above the ring: that path starts a second
|
||||
// group of the field's color, above the black one.
|
||||
let want: Vec<String> = if method == "overlapping" { order.iter().chain(order.first()).cloned().collect() } else { order.clone() };
|
||||
assert_eq!(names(&n), want, "{method}");
|
||||
assert_eq!(names(&n)[seams..], want, "{method}");
|
||||
assert!(n.children().unwrap().iter().take(seams).all(|c| *c == kids[0]), "{method}: the seam fills stay as they are");
|
||||
// Each group holds its color's paths, and in turn they are the paths of the trace without
|
||||
// byColor, in their order.
|
||||
let groups = n.children().unwrap();
|
||||
let groups = &n.children().unwrap()[seams..];
|
||||
for c in groups {
|
||||
assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}");
|
||||
assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name);
|
||||
@@ -6033,8 +6106,8 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
// Release to Layers: a sublayer for each color, in the same order.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap();
|
||||
let subs = ids(&r);
|
||||
assert_eq!(subs.len(), want.len(), "{method}");
|
||||
for (sub, name) in subs.iter().zip(&want) {
|
||||
assert_eq!(subs.len(), seams + want.len(), "{method}");
|
||||
for (sub, name) in subs[seams..].iter().zip(&want) {
|
||||
let l = node(&s, *sub);
|
||||
assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}");
|
||||
assert_eq!(names(&l), std::slice::from_ref(name), "{method}");
|
||||
@@ -6051,7 +6124,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let mut got = names(&node(&s, g));
|
||||
got.sort();
|
||||
assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]);
|
||||
assert_eq!(got, ["#000000", "#c82832", "#f0f0dc", "Seam Fills"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
|
||||
|
||||
@@ -6086,7 +6159,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
});
|
||||
add_image(&mut s, &stripes, Affine::IDENTITY);
|
||||
let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })));
|
||||
assert!(traced_paths(&node(&s, g)).iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })));
|
||||
let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("Auto Grouping"), "{e}");
|
||||
assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace"));
|
||||
@@ -6103,8 +6176,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
add_image(&mut s, &r, Affine::IDENTITY);
|
||||
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
|
||||
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
|
||||
let paths: Vec<(u64, String)> =
|
||||
node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect();
|
||||
let paths: Vec<(u64, String)> = traced_paths(&node(&s, g)).iter().map(|c| (c.id.0, hex(c))).collect();
|
||||
let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect();
|
||||
colors.sort();
|
||||
colors.dedup();
|
||||
@@ -6116,14 +6188,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
|
||||
let mut got = names(&node(&s, g));
|
||||
got.sort();
|
||||
assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]);
|
||||
assert_eq!(got, ["#28a03c", "Ink", "Seam Fills", "Sky 50%"]);
|
||||
// A swatch named as another color's #rrggbb still gets a group of its own.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap();
|
||||
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
|
||||
let mut got = names(&node(&s, g));
|
||||
got.sort();
|
||||
assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]);
|
||||
assert_eq!(got, ["#28a03c", "#28a03c", "Seam Fills", "Sky 50%"]);
|
||||
|
||||
// Gradient fills share one group, and a stroked line goes by its stroke.
|
||||
let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
|
||||
@@ -6192,7 +6264,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
add_image(&mut s, &stripes, Affine::IDENTITY);
|
||||
let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"});
|
||||
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
|
||||
let paths: Vec<Arc<Node>> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect();
|
||||
let paths = traced_paths(&node(&s, g));
|
||||
assert_eq!(paths.len(), 3);
|
||||
let (first, middle, last) = (&paths[0], &paths[1], &paths[2]);
|
||||
s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap();
|
||||
@@ -6220,7 +6292,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
for how in ["move", "transform", "shadow", "beside"] {
|
||||
let (flat, _) = edited(false, how);
|
||||
let (s, g) = edited(true, how);
|
||||
assert_eq!(names(&node(&s, g)).len(), 3, "{how}");
|
||||
assert_eq!(names(&node(&s, g)).len(), 4, "{how}: the seam fills and three colors");
|
||||
assert!(shot(&s) == shot(&flat), "{how}: the edited art changed");
|
||||
}
|
||||
|
||||
@@ -6230,14 +6302,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
add_image(&mut s, &stripes, Affine::IDENTITY);
|
||||
let limited = json!({"mode": "color", "palette": "limited", "colors": 3});
|
||||
let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap());
|
||||
let paths: Vec<u64> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect();
|
||||
let paths: Vec<u64> = traced_paths(&node(&s, g)).iter().map(|c| c.id.0).collect();
|
||||
assert_eq!(paths.len(), 3);
|
||||
s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap();
|
||||
s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap();
|
||||
let groups = node(&s, g).children().unwrap().clone();
|
||||
assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]);
|
||||
assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(names(&node(&s, g)), ["Seam Fills", "Sky 50%", "Sky 50%"]);
|
||||
assert_eq!(groups.iter().skip(1).map(|c| c.children().unwrap().len()).collect::<Vec<_>>(), [1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6289,7 +6361,7 @@ fn image_trace_make_and_expand_and_color_presets() {
|
||||
assert_eq!(res["paths"], 4);
|
||||
let g = node(&s, NodeId(res["id"].as_u64().unwrap()));
|
||||
assert!(g.name.is_none());
|
||||
assert_eq!(g.children().unwrap().len(), 4);
|
||||
assert_eq!(traced_paths(&g).len(), 4);
|
||||
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such Preset"})).is_err());
|
||||
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ mod logo;
|
||||
mod mosaic;
|
||||
mod opaque;
|
||||
mod quantize;
|
||||
mod seam;
|
||||
mod shapes;
|
||||
mod smooth;
|
||||
mod subpixel;
|
||||
@@ -71,6 +72,7 @@ pub use gradient::TracedGradient;
|
||||
pub use mosaic::mosaic;
|
||||
pub use opaque::{ALPHA_MAX_DECODE_BYTES, OUTLINE_MAX_ANCHORS, OUTLINE_MAX_PIXELS, decode_alpha, opaque_outline};
|
||||
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
|
||||
pub use seam::{SEAM_WIDTH, seam_fills};
|
||||
pub use shapes::TracedShape;
|
||||
use subpixel::Crack;
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Abutting with Fills: seam fills, thin strokes under the filled paths along the edges they share.
|
||||
//!
|
||||
//! Abutting paths meet edge to edge, so a renderer that anti-aliases each path on its own leaves
|
||||
//! the pixels along a shared edge partly covered by each path and lets what is behind them show
|
||||
//! through as a light hairline. A stroke along the edge in the color of the path drawn first,
|
||||
//! under every path, fills those pixels with that color: drawing the two paths over it then gives
|
||||
//! each pixel the colors of the two paths in the shares each covers. The paths themselves are not
|
||||
//! changed, and an edge a path shares with no other path (the image's border, a transparent area,
|
||||
//! a color left out) gets no stroke.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, SubPath};
|
||||
|
||||
use super::params::{Method, Mode, TraceParams};
|
||||
use super::pipeline::TracedPath;
|
||||
|
||||
/// How wide each seam fill's stroke is, in pixels: it covers every pixel the shared edge passes
|
||||
/// through, at any angle (a pixel's corner is up to 0.71 px from a line through it).
|
||||
pub const SEAM_WIDTH: f64 = 1.5;
|
||||
|
||||
/// A segment, the same for the segment and the segment run backwards: two 64-bit hashes of its
|
||||
/// points' bits, 16 bytes a segment instead of 64. Two different segments with the same key would
|
||||
/// only give the lower one's color a stroke along a piece it does not share.
|
||||
type Key = u128;
|
||||
|
||||
fn key(a: &Anchor, b: &Anchor) -> Key {
|
||||
let bits = |[p, q, r, s]: [Point; 4]| [p.x, p.y, q.x, q.y, r.x, r.y, s.x, s.y].map(f64::to_bits);
|
||||
let points = bits([a.p, a.h_out, b.h_in, b.p]).min(bits([b.p, b.h_in, a.h_out, a.p]));
|
||||
let hash = |seed: u8| {
|
||||
let mut h = DefaultHasher::new();
|
||||
(seed, points).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
(u128::from(hash(0)) << 64) | u128::from(hash(1))
|
||||
}
|
||||
|
||||
/// The seam fills of a trace made with `params`: for Abutting with Fills (not Flat Logo), thin
|
||||
/// strokes to draw under every path of `paths` (bottom-most first). For each color, one path
|
||||
/// stroked [`SEAM_WIDTH`] pixels wide along the edges where a filled path of that color meets a
|
||||
/// filled path drawn after it, with `color` and `gradient` that path's; the paths with a gradient
|
||||
/// get one each. Bottom-most first, as the paths they lie under. The stroke has at most as many
|
||||
/// anchors as `paths` (each of its segments is a segment of a path), and finding them keeps about
|
||||
/// 40 bytes for each segment of `paths` while it runs. Empty for any other trace.
|
||||
pub fn seam_fills(paths: &[TracedPath], params: &TraceParams) -> Vec<TracedPath> {
|
||||
if params.method != Method::Abutting || !params.fills || params.mode == Mode::Logo {
|
||||
return vec![];
|
||||
}
|
||||
fills(paths)
|
||||
}
|
||||
|
||||
/// The segments of `sp`: each anchor and the one after it (back to the first, if closed).
|
||||
fn segments(sp: &SubPath) -> impl Iterator<Item = (&Anchor, &Anchor)> {
|
||||
let n = sp.anchors.len();
|
||||
let count = if sp.closed { n } else { n.saturating_sub(1) };
|
||||
(0..count).filter_map(move |k| Some((sp.anchors.get(k)?, sp.anchors.get((k + 1) % n)?)))
|
||||
}
|
||||
|
||||
/// [`seam_fills`] of Abutting `paths`, whatever the settings.
|
||||
fn fills(paths: &[TracedPath]) -> Vec<TracedPath> {
|
||||
// For each segment, the first path it is in, and whether another path has it too.
|
||||
let mut owner: HashMap<Key, (usize, bool)> = HashMap::new();
|
||||
for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) {
|
||||
for sp in &p.path.subpaths {
|
||||
for (a, b) in segments(sp).filter(|(a, b)| a.p != b.p) {
|
||||
let e = owner.entry(key(a, b)).or_insert((i, false));
|
||||
if e.0 != i {
|
||||
e.1 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// One fill per color (per path, for a gradient), in the order of the first path it lies under.
|
||||
let mut out: Vec<TracedPath> = vec![];
|
||||
let mut by_color: HashMap<[u8; 3], usize> = HashMap::new();
|
||||
for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) {
|
||||
let mut runs: Vec<SubPath> = vec![];
|
||||
for sp in &p.path.subpaths {
|
||||
let lower = |a: &Anchor, b: &Anchor| a.p != b.p && owner.get(&key(a, b)) == Some(&(i, true));
|
||||
let marks: Vec<bool> = segments(sp).map(|(a, b)| lower(a, b)).collect();
|
||||
let n = sp.anchors.len();
|
||||
if marks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if sp.closed && marks.iter().all(|&m| m) {
|
||||
runs.push(SubPath::new(sp.anchors.clone(), true));
|
||||
continue;
|
||||
}
|
||||
// Start after an unmarked segment, so that a run across the subpath's first anchor
|
||||
// stays one run.
|
||||
let start = if sp.closed { marks.iter().position(|&m| !m).map_or(0, |k| k + 1) } else { 0 };
|
||||
let mut run: Vec<Anchor> = vec![];
|
||||
for j in 0..marks.len() {
|
||||
let k = (start + j) % marks.len();
|
||||
let (Some(&a), Some(&b)) = (sp.anchors.get(k), sp.anchors.get((k + 1) % n)) else { continue };
|
||||
if marks.get(k).copied().unwrap_or(false) {
|
||||
if run.is_empty() {
|
||||
run.push(Anchor { h_in: a.p, ..a });
|
||||
}
|
||||
run.push(b);
|
||||
} else if !run.is_empty() {
|
||||
runs.push(open_run(std::mem::take(&mut run)));
|
||||
}
|
||||
}
|
||||
if !run.is_empty() {
|
||||
runs.push(open_run(run));
|
||||
}
|
||||
}
|
||||
if runs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let slot = if p.gradient.is_some() { None } else { by_color.get(&p.color).copied() };
|
||||
match slot.and_then(|s| out.get_mut(s)) {
|
||||
Some(fill) => fill.path.subpaths.extend(runs),
|
||||
None => {
|
||||
if p.gradient.is_none() {
|
||||
by_color.insert(p.color, out.len());
|
||||
}
|
||||
out.push(TracedPath {
|
||||
path: PathData::new(runs),
|
||||
color: p.color,
|
||||
pixels: 0,
|
||||
stroke: Some(SEAM_WIDTH),
|
||||
shape: None,
|
||||
gradient: p.gradient.clone(),
|
||||
square_caps: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An open run of anchors: the last one has no handle out.
|
||||
fn open_run(mut run: Vec<Anchor>) -> SubPath {
|
||||
if let Some(last) = run.last_mut() {
|
||||
last.h_out = last.p;
|
||||
}
|
||||
SubPath::new(run, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Method, Mode, Raster, TraceParams, trace};
|
||||
|
||||
fn polygon(rings: &[&[(f64, f64)]], color: u8) -> TracedPath {
|
||||
let subpaths = rings.iter().map(|r| SubPath::polyline(&r.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true)).collect();
|
||||
TracedPath { path: PathData::new(subpaths), color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false }
|
||||
}
|
||||
|
||||
fn points(sp: &SubPath) -> Vec<(f64, f64)> {
|
||||
sp.anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
|
||||
}
|
||||
|
||||
/// Each segment of `p`'s subpaths, by [`key`].
|
||||
fn keys(p: &TracedPath) -> Vec<Key> {
|
||||
p.path.subpaths.iter().flat_map(|sp| segments(sp).map(|(a, b)| key(a, b)).collect::<Vec<_>>()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_edge_gets_a_stroke_in_the_color_of_the_path_drawn_first() {
|
||||
let left = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10);
|
||||
let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20);
|
||||
let fills = fills(&[left.clone(), right.clone()]);
|
||||
assert_eq!(fills.len(), 1, "{fills:?}");
|
||||
assert_eq!((fills[0].color, fills[0].stroke, fills[0].gradient.as_ref()), ([10; 3], Some(SEAM_WIDTH), None));
|
||||
assert_eq!(fills[0].path.subpaths.len(), 1);
|
||||
let sp = &fills[0].path.subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
assert_eq!(points(sp), [(2.0, 0.0), (2.0, 2.0)]);
|
||||
// Drawn the other way round, the stroke takes the other color.
|
||||
let swapped = super::fills(&[right, left]);
|
||||
assert_eq!(swapped.len(), 1);
|
||||
assert_eq!((swapped[0].color, points(&swapped[0].path.subpaths[0])), ([20; 3], vec![(2.0, 2.0), (2.0, 0.0)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_island_gets_a_closed_stroke_in_the_color_around_it() {
|
||||
let hole: &[(f64, f64)] = &[(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0)];
|
||||
let around = polygon(&[&[(0.0, 0.0), (6.0, 0.0), (6.0, 6.0), (0.0, 6.0)], hole], 10);
|
||||
let island = polygon(&[&[(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], 20);
|
||||
let fills = fills(&[around, island]);
|
||||
assert_eq!(fills.len(), 1, "{fills:?}");
|
||||
assert_eq!(fills[0].color, [10; 3]);
|
||||
assert_eq!(fills[0].path.subpaths.len(), 1);
|
||||
assert!(fills[0].path.subpaths[0].closed);
|
||||
assert_eq!(points(&fills[0].path.subpaths[0]), hole);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_run_across_a_subpaths_first_anchor_stays_one_stroke() {
|
||||
// The left square starts halfway along the edge it shares; the right one splits that edge
|
||||
// at the same point.
|
||||
let left = polygon(&[&[(2.0, 1.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0), (2.0, 0.0)]], 10);
|
||||
let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0), (2.0, 1.0)]], 20);
|
||||
let fills = fills(&[left, right]);
|
||||
assert_eq!(fills.len(), 1, "{fills:?}");
|
||||
assert_eq!(fills[0].path.subpaths.len(), 1);
|
||||
assert_eq!(points(&fills[0].path.subpaths[0]), [(2.0, 0.0), (2.0, 1.0), (2.0, 2.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_shared_with_no_other_filled_path_get_no_stroke() {
|
||||
let square = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10);
|
||||
assert_eq!(fills(std::slice::from_ref(&square)), []);
|
||||
// A stroked line along the square's edge is not a filled neighbour.
|
||||
let mut line = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20);
|
||||
line.stroke = Some(1.0);
|
||||
assert_eq!(fills(&[square, line]), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_of_one_color_share_one_stroke_and_gradients_keep_their_own() {
|
||||
let a = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10);
|
||||
let b = polygon(&[&[(0.0, 4.0), (2.0, 4.0), (2.0, 6.0), (0.0, 6.0)]], 10);
|
||||
let c = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 6.0), (2.0, 6.0), (2.0, 4.0), (2.0, 2.0)]], 20);
|
||||
let fills = fills(&[a, b, c]);
|
||||
assert_eq!(fills.len(), 1, "{fills:?}");
|
||||
assert_eq!(fills[0].path.subpaths.iter().map(points).collect::<Vec<_>>(), [vec![(2.0, 0.0), (2.0, 2.0)], vec![(2.0, 4.0), (2.0, 6.0)]]);
|
||||
}
|
||||
|
||||
/// Two colors side by side, the edge between them anti-aliased at `x = 10.4`.
|
||||
fn two_colors() -> Raster {
|
||||
Raster::from_fn(24, 16, |x, _| {
|
||||
let share = (10.4 - f64::from(x)).clamp(0.0, 1.0);
|
||||
let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8;
|
||||
[mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255]
|
||||
})
|
||||
}
|
||||
|
||||
fn color(method: Method) -> TraceParams {
|
||||
TraceParams { mode: Mode::Color, colors: 2, method, ignore_white: false, ..TraceParams::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abutting_trace_strokes_the_edges_its_filled_paths_share() {
|
||||
let params = color(Method::Abutting);
|
||||
let res = trace(&two_colors(), ¶ms);
|
||||
assert_eq!(res.paths.len(), 2, "{res:?}");
|
||||
let seams = seam_fills(&res.paths, ¶ms);
|
||||
assert_eq!(seams.len(), 1, "{seams:?}");
|
||||
let seam = &seams[0];
|
||||
assert_eq!((seam.color, seam.stroke), (res.paths[0].color, Some(SEAM_WIDTH)));
|
||||
// Every piece of the stroke is a piece of both paths, and it runs from the image's top to
|
||||
// its bottom along the edge, not along the image's border.
|
||||
let (lower, upper) = (keys(&res.paths[0]), keys(&res.paths[1]));
|
||||
assert!(keys(seam).iter().all(|k| lower.contains(k) && upper.contains(k)));
|
||||
assert!(seam.path.anchor_count() <= res.anchor_count());
|
||||
for a in seam.path.subpaths.iter().flat_map(|sp| &sp.anchors) {
|
||||
assert!((a.p.x - 10.4).abs() < 0.5, "{a:?}");
|
||||
}
|
||||
let ys: Vec<f64> = seam.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p.y)).collect();
|
||||
assert_eq!((ys.iter().copied().fold(f64::INFINITY, f64::min), ys.iter().copied().fold(f64::NEG_INFINITY, f64::max)), (0.0, 16.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_abutting_fills_get_seam_strokes() {
|
||||
let seams = |img: &Raster, params: &TraceParams| seam_fills(&trace(img, params).paths, params);
|
||||
let img = two_colors();
|
||||
assert_eq!(seams(&img, &color(Method::Overlapping)), []);
|
||||
assert_eq!(seams(&img, &TraceParams { fills: false, strokes: true, ..color(Method::Abutting) }), []);
|
||||
// Flat Logo's shapes are stacked, whatever the method says.
|
||||
let abutting = trace(&img, &color(Method::Abutting));
|
||||
assert_eq!(seam_fills(&abutting.paths, &TraceParams { mode: Mode::Logo, ..color(Method::Abutting) }), []);
|
||||
// A color left out leaves no neighbour: a dark bar on white, with Ignore White.
|
||||
let bar = Raster::from_fn(24, 16, |x, _| if (8..16).contains(&x) { [0, 0, 0, 255] } else { [255; 4] });
|
||||
let bw = TraceParams { mode: Mode::BlackAndWhite, method: Method::Abutting, ignore_white: true, ..TraceParams::default() };
|
||||
let res = trace(&bar, &bw);
|
||||
assert_eq!(res.paths.len(), 1, "{res:?}");
|
||||
assert_eq!(seam_fills(&res.paths, &bw), []);
|
||||
// Both colors traced, the bar's two sides are shared.
|
||||
let both = seams(&bar, &TraceParams { ignore_white: false, ..bw });
|
||||
assert_eq!(both.iter().map(|s| s.path.subpaths.len()).sum::<usize>(), 2, "{both:?}");
|
||||
}
|
||||
}
|
||||
+10
@@ -3691,6 +3691,16 @@ already walks Image Trace. Short agent entry points:
|
||||
black ring around a white disc around a black dot, black gets two groups. `layer.releaseToLayers` on the expanded group then
|
||||
gives each group a sublayer. A trace made with `autoGrouping` is refused. Expanding by color also refuses a trace
|
||||
with a live Pathfinder effect.
|
||||
An Abutting trace with Fills (not Flat Logo) also holds a group named `Seam Fills` under its paths: for each color, a
|
||||
stroke 1.5 image pixels wide (at the image's larger scale, when it is stretched more one way) with butt caps and round joins along the edges where a filled path of that color meets a
|
||||
filled path drawn after it, in that color (a gradient path's stroke takes its gradient). Without it, a renderer that
|
||||
anti-aliases each path on its own shows a light hairline along every shared edge. The traced paths and their shared
|
||||
edges are the same with or without it, and edges against the image's border, a transparent area or a color left out
|
||||
get no stroke. A trace whose paths have more than half of the trace's anchor limit (8,000,000, 2,000,000 on the web)
|
||||
has no seam fills, so that paths and seam fills together stay within the limit. Both Expand commands keep the group, and `byColor` leaves it under every color's group. The strokes
|
||||
are separate objects: after Expand, recoloring, moving or deleting a traced path leaves them as they are, and deleting
|
||||
the group removes them. The reply's
|
||||
`paths` and `anchors` count the traced paths only.
|
||||
With `keepColors` in `params`, the reply's `warnings` name each listed color that no traced color matches, and say
|
||||
when nothing was traced.
|
||||
`imageTrace.presets.save {name?, newName?, preset?, enhanced?, params?}` saves tracing settings as a preset kept with
|
||||
|
||||
@@ -1476,6 +1476,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.
|
||||
- **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`.
|
||||
- **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`.
|
||||
- **Image Trace, Abutting seam fills:** an Abutting trace with Fills holds a `Seam Fills` group under its paths, with 1.5 px strokes along the edges two filled paths share, in the lower path's color (VectorCraft's own width), so renderers that anti-alias each path on its own show no light hairline along those edges. The paths and their shared edges are unchanged. Tests: `seam::tests` in `crates/trace`, `image_trace_abutting_seam_fills_sit_under_the_paths` in `crates/engine`. Scores and hours unchanged.
|
||||
- **Image Trace, Auto Color rare colours:** the Automatic palette keeps colours too rare for its area count when they are distinct (Oklab) and drawn as coherent regions, at most six, protected from mixed-colour merging and Noise; code-drawn stem, highlight, low-contrast and scattered-noise tests (see [quality notes](image-trace-quality.md#auto-color-keeps-rare-colours)).
|
||||
- **Image Trace, nearly flat dark grounds:** with a Limited palette of 9 to 16 colors (16 Colors, and Document Library without swatches), by VectorCraft's own rule, a cut whose two sides both have a dark gray mean (Oklab lightness under 0.30, chroma under 0.03) and lie less than 0.03 apart in Oklab has its gain scaled by (distance / 0.03)². A near-black ground with faint noise then traces in one color, and the color it would have taken goes to another cut. Code-drawn tests `a_nearly_flat_dark_ground_keeps_one_color`, `only_a_limited_palette_of_nine_to_sixteen_colors_weighs_alike_sides` and `a_dark_colored_figure_on_a_nearly_flat_dark_ground_keeps_its_color` in `crates/trace`.
|
||||
- **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`.
|
||||
|
||||
Reference in New Issue
Block a user