Compare commits

..
Author SHA1 Message Date
bflatastic 72faa8a154 WIP, not gated: gradients grown over noise and soft rims, largest area first; radial gradients (truth input, engine mapping) 2026-10-13 14:26:53 +07:00
bflatastic 6cb9c917d2 Image Trace: enhanced versions of the presets
Each built-in preset has an enhanced version (`enhanced: true` in
`imageTrace.make`, an Enhanced checkbox under the panel's preset): the preset
with Auto Grouping, and with Shapes in all but High Fidelity Photo (and Flat
Logo, which fits shapes of its own). Gradients stay an option. The legacy
presets, and documents traced with them, are as they were.

So that an enhanced preset is no worse than its preset on that preset's inputs:
- A hole that one colour fills is the same shape as the path that fills it, so
  the two still share one outline (a shape beside a fitted hole left slivers).
- A rectangle is as large as where its sides lie on average, not their farthest
  points.

The harness measures every enhanced preset beside its preset.
2026-10-13 14:14:26 +07:00
bflatastic 7b41de9ef6 Image Trace: stroked lines get square caps where their ink is cut square
Create: Strokes drew every line with round caps: a square-ended line 10 px wide
had its corners 2.94 px outside the stroke.

- A line's ends are square when its ink reaches as far a third of the width to
  either side of its middle as along it (a round end stops short there by a
  seventh of the width); told from lines 3 px wide and more, all the ends of a
  line together. The stroke then has square caps (`TracedPath::square_caps`).
- The last points of a wide line's skeleton turn towards a corner of its end.
  The end is now placed on the line the skeleton followed a width back, and may
  be pulled back as well as pushed out to where the ink ends.

Outline on the thin-line inputs: largest distance from the art 2.94 -> 1.30 px
(clean 2.94 -> 0.41; aliased 0.88 -> 1.30), mean 0.055 px as before.
2026-10-13 13:39:03 +07:00
bflatastic b934b44ca8 Image Trace: Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art
- A sharp corner may fall up to 0.75 px short within 3 px of where it should be:
  anti-aliasing and blur take that much off a corner (not on a thin bar, which
  is all corners).
- A rounded rectangle gets the radius that fits its outline best, searched
  around the one its area says, instead of that one alone.
- A rectangle's direction is tried along its longest straight side, the longer
  axis of its area and the pixel grid: a blurred one may have no straight side.
- A regular polygon's sides, corners and size are read from the outline's shape
  (how its distance from the middle goes round, and its area), not from how many
  anchors the fitter gave it.
- An outline without anti-aliasing is known to half a pixel: it gets half as
  much room again.

Plain shapes found with Shapes on, all sizes and variants: Default 57% -> 98%,
6 Colors 62% -> 100%. The harness counts false shapes: none on any input but one
thin line (a rectangle 2 px wide) whose trace is 0.70 px off with or without
Shapes.
2026-10-13 13:24:27 +07:00
bflatastic 62036eac6c Image Trace: Flat Logo takes the tones of an unevenly lit colour for one colour
A scan's paper is a tenth darker in one corner than in another. Flat Logo found
each tone of it as a colour of its own and traced the bands and the specks
between them: on the scanned inputs 470 paths, 21 px from the art on average.

Two palette colours are now one when the pixels between them are no fewer than
a third of those at the lesser of the two: flat colours leave only their shared
edge between them, tones of one colour fill the gap. Whether the image is flat
art at all is still asked of the tones as found.

Flat Logo on its inputs, all sizes and variants: 3.106 -> 0.090 px off on
average, 88.5 -> 0.82 px at most, 69.9 -> 3.9 paths; clean, aliased, blurred
and noisy art as before.
2026-10-13 13:01:01 +07:00
bflatastic 7af5790b71 Image Trace: levelling moves no end of a line by more than 1.6 px
Snap Curves To Lines made every straight piece within 2.5 degrees of level or
upright exactly so, whatever its length: a 200 px line 2 degrees off had each end
moved 3.5 px. Now a piece is levelled only when neither end moves more than
1.6 px; a longer line that far off is left as the image has it.

Technical Drawing (the preset with the option on) on its inputs: largest
distance from the art 4.21 -> 1.46 px (1.43 before levelling existed), mean
0.178 -> 0.126 px; on the askew inputs 98% -> 70% of the outline comes out level
or upright.
2026-10-13 12:40:24 +07:00
34 changed files with 1547 additions and 670 deletions

No files matched your search

+1 -2
View File
@@ -6,7 +6,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -127,7 +127,6 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-13 | Image Trace: neighbouring colours share one fitted edge (gaps between them 22 to 50 px of area down to under 2), a 16 megapixel photo traces in 0.9 GB (was 3.9), stroked centre lines lie in the middle of their ink at its width (0.30 to 0.05 px off, 18 to 3 anchors a stroke), nearly level lines are levelled, more sharp corners are kept; new: Ignore Color, the Auto Color and Outline presets, and the Shapes, Auto Grouping and Gradients options (off by default) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
+17 -14
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\"|\"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), 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), 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, white is always its own colour, at most ten; 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 unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo and Flat Logo), 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), 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), 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, or outwards from a point, is one path with a linear or radial 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, white is always its own colour, at most ten; 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 unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<_>>() }))
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
}),
]
}
@@ -657,7 +657,9 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
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)")))?;
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
let base = if enhanced { tr::enhanced_preset(name) } else { tr::preset(name) };
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
@@ -727,12 +729,14 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
live
}
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
/// document by `xf`.
/// The gradient fill for a gradient the tracer found (in the image's pixels), placed in the
/// document by `xf` (a radial one: its middle and a point on its outermost circle; a circle
/// still where the image is scaled evenly).
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
let kind = if g.radial { GradientKind::Radial } else { GradientKind::Linear };
let mut paint = GradientPaint::new(Gradient { kind, stops });
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
@@ -778,12 +782,11 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
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".
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
// else "Custom".
let named = str_param(p, "preset").unwrap_or("Default");
let preset_label = match tr::preset(named) {
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(&params_json) => named.to_string(),
_ => "Custom".to_string(),
};
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(&params_json);
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
let roots = root_ids(s, p)?;
// The image (inside a trace group, or selected directly).
let (target, img) = {
@@ -848,10 +851,10 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
// Gradients: a path the tracer found to be a linear gradient is filled with it.
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
// Traced lines: round corners, and ends round or square as the tracer found them.
Some(width) => Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Round,
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
join: LineJoin::Round,
..StrokeLayer::new(paint, width * unit)
})],
+34
View File
@@ -771,6 +771,40 @@ fn image_trace_gradients_fills_with_a_gradient() {
assert!((a.x - 50.0).abs() < 3.0 && (b.x - 250.0).abs() < 3.0 && (a.y - b.y).abs() < 2.0, "{geom:?}");
let flat = make(false);
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
// Colours going out from a point: a radial gradient, its middle where the image has it.
let round = vectorcraft_trace::Raster::from_fn(120, 100, |x, y| {
let t = ((f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 40.0) / 90.0).min(1.0);
[(250.0 - 200.0 * t) as u8, (230.0 - 150.0 * t) as u8, 90, 255]
});
let mut s = session();
add_image(&mut s, &round, Affine::translate((10.0, 20.0)));
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": true}})).unwrap();
let paths = traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())));
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
assert_eq!((paths.len(), g.gradient.kind), (1, vectorcraft_color::GradientKind::Radial));
let middle = g.geom.as_ref().unwrap().start;
assert!((middle.x - 60.0).abs() < 1.5 && (middle.y - 60.0).abs() < 1.5, "{middle:?}");
}
/// A preset's enhanced version: the preset with Auto Grouping and Shapes, under the preset's name.
#[test]
fn image_trace_enhanced_presets() {
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "enhanced": true})).unwrap();
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
let stored = n.extras().trace.clone().unwrap();
assert_eq!(stored["preset"], "6 Colors");
assert_eq!((stored["params"]["shapes"].as_bool(), stored["params"]["autoGrouping"].as_bool()), (Some(true), Some(true)));
// The disc of the test image is a live ellipse, in a group with nothing (it has nothing inside).
let shapes: Vec<_> = traced_paths(&n).into_iter().filter(|c| matches!(&c.kind, NodeKind::Path { live: Some(_), .. })).collect();
assert_eq!(shapes.len(), 1);
// Listed with each preset; the legacy preset is as it was.
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
let six = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "6 Colors").unwrap().clone();
assert_eq!((six["params"]["shapes"].as_bool(), six["enhanced"]["shapes"].as_bool()), (Some(false), Some(true)));
assert_eq!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "High Fidelity Photo").unwrap()["enhanced"]["shapes"], false);
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such", "enhanced": true})).is_err());
}
#[test]
+32 -8
View File
@@ -36,6 +36,8 @@ pub(crate) struct Line {
/// Stroke width in pixels.
pub width: f64,
pub pixels: usize,
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
pub square: bool,
}
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
@@ -374,22 +376,44 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
// wide as its pixels make it).
widths.sort_by(f64::total_cmp);
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
// Each end goes where the ink ends, less the half width its round cap adds.
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
// of the line's middle as along it, where a round end stops short by a seventh of the width.
let (mut short_by, mut told) = (0.0, 0);
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
for k in 0..2 {
if r.ends[k].is_some_and(|e| meet[e] > 1) {
continue;
}
let n = r.pts.len();
let (tip, inner) = if k == 0 { (r.pts[0], r.pts[3.min(n - 1)]) } else { (r.pts[n - 1], r.pts[n - 1 - 3.min(n - 1)]) };
let d = tip - inner;
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
// The last points of a wide line's skeleton turn towards a corner of its end: the end
// lies where the line was going a width back, and they are put on that line.
let back = (width.round() as usize + 2).max(3);
let wide = width >= 3.0 && n > 2 * back;
let (tip, inner) = (r.pts[at(0)], r.pts[at(back)]);
let d = if wide { inner - r.pts[at(2 * back)] } else { tip - inner };
if d.hypot() < 1e-9 {
continue;
}
let more = ink.beyond(tip, d / d.hypot()) - width / 2.0;
if more.abs() > 0.05 && more > -1.0 {
let to = tip + d / d.hypot() * more;
if k == 0 { r.pts[0] = to } else { r.pts[n - 1] = to }
let along = d / d.hypot();
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
if wide {
for i in 0..back {
r.pts[at(i)] = on_line(r.pts[at(i)]);
}
}
let tip = on_line(tip);
let reach = ink.beyond(tip, along);
if width >= 3.0 {
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
short_by += short / width;
told += 1;
}
let more = reach - width / 2.0;
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
r.pts[at(0)] = tip + along * more;
}
}
}
@@ -409,7 +433,7 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
if subs.is_empty() {
return None;
}
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
Some(Line { path: PathData::new(subs), width, pixels: comp.len(), square: told > 0 && short_by / f64::from(told) < 0.07 })
}
/// What the image says of a line's ink.
+12 -3
View File
@@ -54,9 +54,13 @@ fn before(a: Point, b: Point) -> std::cmp::Ordering {
/// Lines within this of level or upright are made exactly so (the tangent of 2.5°).
const NEARLY_UPRIGHT: f64 = 0.0437;
/// The farthest (px) levelling a line may move either of its ends.
const LEVEL_REACH: f64 = 1.6;
/// Snap Curves To Lines: the straight pieces of `segs` (a chain; `closed` when its end joins its
/// start) that are within 2.5° of level or upright and at least 2 px long are made exactly level
/// or upright, by moving their ends across them: both to their mean, or, in an open chain, the
/// or upright (unless that moves an end by more than [`LEVEL_REACH`]), by moving their ends
/// across them: both to their mean, or, in an open chain, the
/// free end to the end that is fixed (with `fixed_ends`, a chain's own ends stay where they are:
/// they are junctions other regions share). The pieces next to a moved end follow it.
fn snap_upright(segs: &mut [Cubic], closed: bool, fixed_ends: bool) {
@@ -96,6 +100,11 @@ fn snap_upright(segs: &mut [Cubic], closed: bool, fixed_ends: bool) {
(false, true) => pick(b),
(false, false) => (pick(a) + pick(b)) / 2.0,
};
// Not when an end would go farther than a pixel and a half or so from where the image has it: a long line a
// degree or two off is that line, not a level one drawn badly.
if (pick(a) - to).abs().max((pick(b) - to).abs()) > LEVEL_REACH {
continue;
}
let moved = |p: Point| if level { Point::new(p.x, to) } else { Point::new(to, p.y) };
if !fixed(ia) {
put(segs, ia, moved(a));
@@ -179,7 +188,7 @@ pub(crate) fn fit_loop(
across: &dyn Fn(Crack) -> (f64, u32),
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
style: &Style,
) -> Option<SubPath> {
) -> Option<(SubPath, bool)> {
let pieces = subpixel::outline(l, w, h, across, junction_at);
let mut segs: Vec<Cubic> = Vec::new();
for piece in &pieces {
@@ -197,7 +206,7 @@ pub(crate) fn fit_loop(
segs.extend(fit_piece(&piece.pts, &style, true));
}
}
subpath(&segs, true)
Some((subpath(&segs, true)?, pieces.iter().any(|p| p.hard)))
}
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
+184 -29
View File
@@ -25,10 +25,14 @@ const MAX_STOPS: usize = 6;
const MIN_SPACING: f64 = 0.08;
/// How far (levels) the stops may be from the colours along the gradient.
const STOP_TOLERANCE: f64 = 2.0;
/// How far (levels) a pixel beside a gradient may be from the gradient's colour there and join it.
const GROW: f64 = 10.0;
/// A linear gradient a traced path is filled with.
/// A gradient a traced path is filled with: linear, or radial (the same colour all round a point).
#[derive(Clone, Debug, PartialEq)]
pub struct TracedGradient {
/// Radial: `start` is the middle, and the stops go outwards to the circle through `end`.
pub radial: bool,
/// The gradient's line, in pixel coordinates: the first stop's colour is at `start` and before
/// it, the last one's at `end` and beyond.
pub start: Point,
@@ -38,10 +42,20 @@ pub struct TracedGradient {
}
impl TracedGradient {
/// How far along the gradient `p` is: 0 at `start`, 1 at `end` (or on the circle through it).
fn along(&self, p: Point) -> f64 {
let d = self.end - self.start;
if d.hypot2() <= 0.0 {
0.0
} else if self.radial {
(p - self.start).hypot() / d.hypot()
} else {
(p - self.start).dot(d) / d.hypot2()
}
}
/// The gradient's colour at `p`.
pub fn color_at(&self, p: Point) -> [f64; 3] {
let d = self.end - self.start;
let t = if d.hypot2() > 0.0 { ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
let t = self.along(p).clamp(0.0, 1.0);
let color = |i: usize| self.stops.get(i).map_or([0.0; 3], |s| s.1.map(f64::from));
let after = self.stops.iter().position(|s| s.0 >= t).unwrap_or(self.stops.len().saturating_sub(1));
let Some(before) = after.checked_sub(1) else { return color(after) };
@@ -52,6 +66,12 @@ impl TracedGradient {
}
}
/// The pixels beside pixel `i` of a `w` × `h` image.
fn beside(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
let (x, y) = (i % w, i / w);
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten()
}
/// The gradients of `img`: for each pixel the gradient it is part of (an index into the list, or
/// `u32::MAX`), and the list.
pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
@@ -103,11 +123,8 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
let mut depth = vec![0u16; w * h];
let mut found = vec![];
let (mut members, mut stack) = (vec![], vec![]);
for seed in 0..w * h {
if seen.get(seed).copied().unwrap_or(true) || !smooth.get(seed).copied().unwrap_or(false) {
continue;
}
// The connected smooth pixels from here.
// The connected smooth pixels from `seed` that `seen` has not: into `members`.
let mut flood = |seed: usize, seen: &mut [bool], members: &mut Vec<usize>| {
members.clear();
stack.push(seed);
if let Some(s) = seen.get_mut(seed) {
@@ -115,8 +132,7 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
}
while let Some(i) = stack.pop() {
members.push(i);
let (x, y) = (i % w, i / w);
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
for j in beside(i, w, h) {
if smooth.get(j).copied().unwrap_or(false) && !seen.get(j).copied().unwrap_or(true) {
if let Some(s) = seen.get_mut(j) {
*s = true;
@@ -125,7 +141,26 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
}
}
}
if members.len() < MIN_PIXELS || found.len() >= 1000 {
};
// The areas, largest first: a gradient that noise or a soft rim broke into several is found in
// its largest piece and grown over the rest.
let mut areas: Vec<(usize, usize)> = vec![];
for seed in 0..w * h {
if !seen.get(seed).copied().unwrap_or(true) && smooth.get(seed).copied().unwrap_or(false) {
flood(seed, &mut seen, &mut members);
if members.len() >= MIN_PIXELS {
areas.push((members.len(), seed));
}
}
}
areas.sort_unstable_by(|a, b| b.cmp(a));
seen.fill(false);
for (_, seed) in areas {
if seen.get(seed).copied().unwrap_or(true) || found.len() >= 1000 {
continue;
}
flood(seed, &mut seen, &mut members);
if members.len() < MIN_PIXELS {
continue;
}
// The gradient is read from the area's inside, where there is one: the pixels at its rim
@@ -135,16 +170,12 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
let rim = far as usize + 2;
let unknown = u16::MAX;
members.iter().for_each(|&i| depth[i] = unknown);
let beside = |i: usize| {
let (x, y) = (i % w, i / w);
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten()
};
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i).any(|j| depth.get(j).copied() == Some(0))).collect();
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i, w, h).any(|j| depth.get(j).copied() == Some(0))).collect();
front.iter().for_each(|&i| depth[i] = 1);
for d in 2..=rim as u16 {
let mut next = vec![];
for &i in &front {
for j in beside(i) {
for j in beside(i, w, h) {
if let Some(slot) = depth.get_mut(j).filter(|s| **s == unknown) {
*slot = d;
next.push(j);
@@ -156,19 +187,34 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
let inside: Vec<usize> = members.iter().copied().filter(|&i| depth.get(i).copied() == Some(unknown)).collect();
let fitted = fit(img, w, if inside.len() >= MIN_PIXELS { &inside } else { &members }, &members, rim);
members.iter().for_each(|&i| depth[i] = 0);
if let Some((g, part)) = fitted {
if let Some((g, mut part)) = fitted {
for &i in &part {
if let Some(o) = of.get_mut(i) {
*o = found.len() as u32;
}
}
// Grown over every pixel next to it that has its colour there: the pixels noise put a
// little off, a soft rim, the pieces noise cut off it.
while let Some(i) = part.pop() {
for j in beside(i, w, h) {
let near = px(j).is_some_and(|c| {
let own = g.color_at(Point::new((j % w) as f64 + 0.5, (j / w) as f64 + 0.5));
(0..3).all(|k| (f64::from(c[k]) - own[k]).abs() <= GROW)
});
if near && of.get(j).copied() == Some(u32::MAX) {
of[j] = found.len() as u32;
seen[j] = true;
part.push(j);
}
}
}
found.push(g);
}
}
(of, found)
}
/// The linear gradient the pixels `members` of `img` (`w` wide) are, if they are one, and those of
/// The gradient (linear, else radial) the pixels `members` of `img` (`w` wide) are, if they are one, and those of
/// `all` (the whole area `members` are the inside of, `rim` pixels in) that have its colours.
fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) -> Option<(TracedGradient, Vec<usize>)> {
let n = members.len() as f64;
@@ -206,14 +252,101 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
let (mxx, mxy, myy) = slope.iter().fold((0.0, 0.0, 0.0), |m, s| (m.0 + s.x * s.x, m.1 + s.x * s.y, m.2 + s.y * s.y));
let spread = ((mxx - myy) / 2.0).hypot(mxy);
let (major, minor) = ((mxx + myy) / 2.0 + spread, (mxx + myy) / 2.0 - spread);
if major <= 0.0 || minor > 0.03 * major {
return None;
}
let angle = 0.5 * (2.0 * mxy).atan2(mxx - myy);
let dir = Vec2::new(angle.cos(), angle.sin());
let line = (major > 0.0 && minor <= 0.03 * major)
.then(|| ramp(img, w, members, all, rim, Axis::Line(middle, Vec2::new(angle.cos(), angle.sin()))))
.flatten();
// Not along one direction: round a point, perhaps.
line.or_else(|| ramp(img, w, members, all, rim, Axis::Round(middle_of_rings(img, w, members)?)))
}
/// What a gradient's colours go along: a line through a point, or outwards from a point.
#[derive(Clone, Copy)]
enum Axis {
Line(Point, Vec2),
Round(Point),
}
impl Axis {
/// How far (px) along it `p` is.
fn along(self, p: Point) -> f64 {
match self {
Axis::Line(through, dir) => (p - through).dot(dir),
Axis::Round(middle) => (p - middle).hypot(),
}
}
/// The point `t` px along it.
fn at(self, t: f64) -> Point {
match self {
Axis::Line(through, dir) => through + dir * t,
Axis::Round(middle) => middle + Vec2::new(t, 0.0),
}
}
}
/// The middle of the pixels `members` if their colour goes round a point: the common middle of the
/// circles that the pixels of one tone lie on, for three tones.
fn middle_of_rings(img: &Raster, w: usize, members: &[usize]) -> Option<Point> {
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 3).map_or([0u8; 3], |p| [p[0], p[1], p[2]]);
// By the channel that changes most.
let (low, high) =
members.iter().fold(([255u8; 3], [0u8; 3]), |m, &i| ([0, 1, 2].map(|k| m.0[k].min(rgb(i)[k])), [0, 1, 2].map(|k| m.1[k].max(rgb(i)[k]))));
let channel = (0..3).max_by_key(|&k| high[k].saturating_sub(low[k]))?;
let mut count = [0usize; 256];
members.iter().for_each(|&i| count[rgb(i)[channel] as usize] += 1);
let tone_at = |share: f64| {
let mut seen = 0;
count.iter().position(|&n| {
seen += n;
seen as f64 >= share * members.len() as f64
})
};
let mut middles = vec![];
for share in [0.3, 0.5, 0.7] {
let tone = tone_at(share)? as i32;
// The circle x² + y² + d x + e y + f = 0 nearest the pixels of that tone, by least squares.
let (mut m, mut v) = ([[0.0f64; 3]; 3], [0.0f64; 3]);
let mut n = 0;
for &i in members.iter().filter(|&&i| (i32::from(rgb(i)[channel]) - tone).abs() <= 1) {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let (row, z) = ([x, y, 1.0], -(x * x + y * y));
for a in 0..3 {
for b in 0..3 {
m[a][b] += row[a] * row[b];
}
v[a] += row[a] * z;
}
n += 1;
}
let det = |m: &[[f64; 3]; 3]| {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
};
let whole = det(&m);
if n < 30 || whole.abs() < 1e-6 {
return None;
}
let solved = |col: usize| {
let mut swapped = m;
(0..3).for_each(|r| swapped[r][col] = v[r]);
det(&swapped) / whole
};
middles.push(Point::new(-solved(0) / 2.0, -solved(1) / 2.0));
}
let mean = (middles.iter().fold(Vec2::ZERO, |s, p| s + p.to_vec2()) / middles.len() as f64).to_point();
(mean.x.is_finite() && mean.y.is_finite() && middles.iter().all(|p| p.distance(mean) <= 3.0)).then_some(mean)
}
/// The gradient the pixels `members` are along `axis`, if they are one (see [`fit`]).
fn ramp(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize, axis: Axis) -> Option<(TracedGradient, Vec<usize>)> {
let at = |i: usize| Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let rgb = |i: usize| img.rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
let radial = matches!(axis, Axis::Round(_));
// The colours along it, a pixel's width at a time.
let along = |i: usize| (at(i) - middle).dot(dir);
let along = |i: usize| axis.along(at(i));
let (t0, t1) = members.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |m, &i| (m.0.min(along(i) - 0.5), m.1.max(along(i) + 0.5)));
// (A radial gradient starts at its middle, whether or not the area reaches it.)
let t0 = if radial { 0.0 } else { t0 };
let length = t1 - t0;
if !(MIN_LENGTH..1e6).contains(&length) {
return None;
@@ -272,7 +405,7 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
_ => stops.push((t, c)),
}
}
let (first, last) = (stops.first()?.0, stops.last()?.0);
let (first, last) = (if radial { 0.0 } else { stops.first()?.0 }, stops.last()?.0);
// A change of colour, not one colour.
let change =
stops.iter().flat_map(|a| stops.iter().map(move |b| (0..3).map(|k| (a.1[k] - b.1[k]).abs()).fold(0.0, f64::max))).fold(0.0, f64::max);
@@ -282,13 +415,19 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
return None;
}
let gradient = TracedGradient {
start: middle + dir * (t0 + first * length),
end: middle + dir * (t0 + last * length),
stops: stops.iter().map(|&(t, c)| ((t - first) / (last - first), c.map(|v| v.round().clamp(0.0, 255.0) as u8))).collect(),
radial,
start: axis.at(t0 + first * length),
end: axis.at(t0 + last * length),
// (A radial one's first colour goes on to its middle.)
stops: stops
.iter()
.enumerate()
.map(|(j, &(t, c))| (if j == 0 { 0.0 } else { (t - first) / (last - first) }, c.map(|v| v.round().clamp(0.0, 255.0) as u8)))
.collect(),
};
// Carried on to the ends of the whole area, each end ramp going on as it goes.
let d = gradient.end - gradient.start;
let place = |i: usize| (at(i) - gradient.start).dot(d) / d.hypot2();
let place = |i: usize| gradient.along(at(i));
let reach = rim as f64 * 1.5 / d.hypot();
let (u0, u1) = all.iter().fold((0.0f64, 1.0f64), |m, &i| (m.0.min(place(i)), m.1.max(place(i))));
let (u0, u1) = (u0.max(-reach), u1.min(1.0 + reach));
@@ -305,6 +444,7 @@ fn fit(img: &Raster, w: usize, members: &[usize], all: &[usize], rim: usize) ->
}
}
let gradient = TracedGradient {
radial,
start: gradient.start + d * u0,
end: gradient.start + d * u1,
stops: stops.into_iter().map(|(u, c)| ((u - u0) / (u1 - u0), c)).collect(),
@@ -423,6 +563,21 @@ mod tests {
assert!(detect(&ramp(96, 96, |x, y| [40.0 + 2.0 * x, 40.0 + 2.0 * y, 90.0])).1.is_empty());
}
#[test]
fn colours_going_round_a_point_are_a_radial_gradient() {
// Yellow at (70, 50) to dark red 80 px out.
let img = ramp(160, 120, |x, y| {
let t = ((x - 70.0).hypot(y - 50.0) / 80.0).min(1.0);
[250.0 - 130.0 * t, 220.0 - 190.0 * t, 60.0 - 30.0 * t]
});
let (_, found) = detect(&img);
let g = found.iter().find(|g| g.radial).expect("a radial gradient");
assert!(g.start.distance(Point::new(70.0, 50.0)) < 1.0, "{:?}", g.start);
assert!(((g.end - g.start).hypot() - 80.0).abs() < 5.0, "{:?}", g);
let (first, last) = (g.stops[0].1, g.stops.last().unwrap().1);
assert!(first[0] > 240 && first[1] > 205 && last[0] < 135 && last[1] < 45, "{:?}", g.stops);
}
#[test]
fn three_stops_are_three_stops() {
let img = ramp(200, 40, |x, _| {
+1 -1
View File
@@ -158,7 +158,7 @@ mod tests {
fn disc(c: (f64, f64), r: f64, color: u8) -> TracedPath {
let path = PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Circle::new(c, r), 0.01));
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None }
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false }
}
#[test]
+76 -7
View File
@@ -430,6 +430,14 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> {
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
}
/// The enhanced version of built-in preset `name`: the preset with the options the enhanced
/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo
/// (and Flat Logo, which has shapes of its own). Gradients stay an option to turn on.
pub fn enhanced_preset(name: &str) -> Option<TraceParams> {
let p = preset(name)?;
Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo") && p.mode != Mode::Logo, ..p })
}
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
/// lines stroked with it.
#[derive(Clone, Debug, PartialEq)]
@@ -446,6 +454,9 @@ pub struct TracedPath {
pub shape: Option<TracedShape>,
/// Gradients: the linear gradient the path is filled with (`color` is its middle colour).
pub gradient: Option<TracedGradient>,
/// Create Strokes: the stroke's ends are square (a cap that goes half the width past the
/// path's end, cut straight), as the line's ink is; otherwise round.
pub square_caps: bool,
}
/// The traced result, bottom-most path first.
@@ -810,6 +821,36 @@ impl Layers<'_> {
shift
}
/// Whether the pixels inside `l` (a loop along pixel edges) all have one label.
fn one_colour_within(&self, l: &Loop) -> bool {
let mut first = None;
let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1)));
for y in y0..y1 {
// The loop's upright edges that pass this row of pixels, left to right: the pixels
// between the first and the second are inside, and so on.
let mut xs: Vec<i32> = l
.pts
.iter()
.zip(l.pts.iter().cycle().skip(1))
.filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1))
.map(|(a, _)| a.0)
.collect();
xs.sort_unstable();
for pair in xs.as_chunks::<2>().0 {
for x in pair[0]..pair[1] {
let label = usize::try_from(y)
.ok()
.zip(usize::try_from(x).ok())
.and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w));
if label.is_none() || *first.get_or_insert(label) != label {
return false;
}
}
}
}
first.is_some()
}
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
/// maximum.
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
@@ -846,14 +887,14 @@ impl Layers<'_> {
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
let min_hole = params.noise.max(1) as i64;
let mut paths = vec![];
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>| {
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>, square_caps: bool| {
let n = path.anchor_count();
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
return false;
}
// (A gradient fills; a stroked line of it is its middle colour.)
let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none());
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient });
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient, square_caps });
true
};
let mut comps = trace_mask(&mask, bw, bh);
@@ -866,27 +907,55 @@ impl Layers<'_> {
}
}
for comp in comps {
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style);
// (`hard`: some of the outline has no anti-aliasing, and is known less exactly.)
let hard = std::cell::Cell::new(false);
let fit = |l: &Loop| {
let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?;
hard.set(hard.get() || is_hard);
Some(sp)
};
let Some(outer) = fit(&comp.outer) else { continue };
let mut subs = vec![outer];
for hole in &comp.holes {
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
continue;
}
subs.extend(fit(hole));
let Some(mut sp) = fit(hole) else { continue };
// Shapes: a hole that is a shape is that shape, as what fills it is (the two are
// one outline: they stay one). Only where one colour fills it: the parts of
// several are not that shape, and would no longer meet it.
if params.shapes
&& area_stroke.is_none()
&& self.one_colour_within(hole)
&& let Some(exact) = shapes::recognise(&sp, hard.get()).and_then(|shape| shape.path().subpaths.into_iter().next())
{
// (Which way round each goes: by the area of the polygon through points along it.)
let turn = |sp: &vectorcraft_geom::SubPath| {
use vectorcraft_geom::ParamCurve;
let pts: Vec<_> =
(0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect();
pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::<f64>()
};
let mut exact = exact;
if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) {
exact.reverse();
}
sp = exact;
}
subs.push(sp);
}
// Shapes: a filled outline without holes that is a shape becomes that shape.
let shape = match subs.as_slice() {
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only),
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only, hard.get()),
_ => None,
};
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
if !add(path, comp.pixels, area_stroke, shape) {
if !add(path, comp.pixels, area_stroke, shape, false) {
return None;
}
}
for line in lines {
if !add(line.path, line.pixels, Some(line.width), None) {
if !add(line.path, line.pixels, Some(line.width), None, line.square) {
return None;
}
}
+55
View File
@@ -166,6 +166,61 @@ pub(crate) fn auto_palette(pix: &[Px], w: usize, h: usize, max_colors: usize) ->
groups.iter().map(|(c, _)| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
}
/// How far apart (in RGB) two tones of one colour may be: a scan's paper is a tenth darker in one
/// corner than in another.
const TONES_APART: f64 = 60.0;
/// `palette` with the tones of one colour made one colour. Two flat colours leave nothing between
/// them but the pixels of their shared edges; where a scan's paper is unevenly lit, or a wash
/// shades off, the pixels between two of its tones are as many as those at either: such a pair is
/// one colour (their mean, by pixels), however many tones of it were found.
pub(crate) fn fold_tones(pix: &[Px], mut palette: Vec<[u8; 3]>) -> Vec<[u8; 3]> {
let apart = |a: [u8; 3], b: [u8; 3]| dist2(a.map(f64::from), b.map(f64::from)).sqrt();
loop {
let close: Vec<(usize, usize)> = (0..palette.len())
.flat_map(|i| (i + 1..palette.len()).map(move |j| (i, j)))
.filter(|&(i, j)| palette.get(i).zip(palette.get(j)).is_some_and(|(a, b)| apart(*a, *b) <= TONES_APART))
.collect();
if close.is_empty() {
return palette;
}
// Each opaque pixel goes to its nearest colour; for a close pair, the pixels of either are
// counted by where they lie from one to the other: at one, between, at the other.
let mut thirds = vec![[0usize; 3]; close.len()];
for p in pix.iter().filter(|p| p[3] > 0.9) {
let c = rgb255(p);
let Some(near) = (0..palette.len()).min_by(|&i, &j| dist2(c, palette[i].map(f64::from)).total_cmp(&dist2(c, palette[j].map(f64::from))))
else {
continue;
};
for (k, &(i, j)) in close.iter().enumerate() {
if near == i || near == j {
let (a, b) = (palette[i].map(f64::from), palette[j].map(f64::from));
let t = (0..3).map(|ch| (c[ch] - a[ch]) * (b[ch] - a[ch])).sum::<f64>() / dist2(a, b).max(1e-9);
thirds[k][if t < 1.0 / 3.0 {
0
} else if t <= 2.0 / 3.0 {
1
} else {
2
}] += 1;
}
}
}
// The pair with the most between it, if that is no fewer than a third of what the lesser
// end has.
let filled = |t: &[usize; 3]| t[1] as f64 / (t[0].min(t[2]).max(1)) as f64;
let Some((k, _)) = thirds.iter().enumerate().filter(|(_, t)| filled(t) >= 1.0 / 3.0).max_by(|a, b| filled(a.1).total_cmp(&filled(b.1)))
else {
return palette;
};
let ((i, j), t) = (close[k], thirds[k]);
let (na, nb) = ((t[0] + t[1] / 2).max(1) as f64, (t[2] + t[1] / 2).max(1) as f64);
palette[i] = [0, 1, 2].map(|ch| ((f64::from(palette[i][ch]) * na + f64::from(palette[j][ch]) * nb) / (na + nb)).round() as u8);
palette.remove(j);
}
}
/// Colours the current palette cannot explain. `residual` is each pixel's error after the best blend
/// of the known classes (see `unmix`); a solid run of pixels (a pixel and its four neighbours agree)
/// with a large error is a colour that is missing, such as the small accent that was too few
+16 -7
View File
@@ -4,7 +4,7 @@ use super::field::{blur_plane, upsample};
use super::geom::{Cubic, finite};
use super::march::{area2, contours};
use super::outline::{Stats, Style, fit_outline};
use super::palette::{WHITE_MIN, auto_palette, flat_fraction, parse_hex, unexplained_colours};
use super::palette::{WHITE_MIN, auto_palette, flat_fraction, fold_tones, parse_hex, unexplained_colours};
use super::restore::RestoreParams;
use super::unmix::{Px, count_ambiguous, unmix};
use crate::{Raster, TraceError, TraceParams, TraceResult, TracedPath};
@@ -317,11 +317,15 @@ fn distance(a: [u8; 3], b: [u8; 3]) -> f64 {
(0..3).map(|k| (f64::from(a[k]) - f64::from(b[k])).powi(2)).sum::<f64>().sqrt()
}
type Colours = Vec<[u8; 3]>;
/// The colours to trace: those the caller named (white is always its own class so it is skipped;
/// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found.
fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<Vec<[u8; 3]>, TraceError> {
/// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found,
/// the tones of one colour folded into it ([`fold_tones`]); and with them the colours before folding.
fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<(Colours, Colours), TraceError> {
if params.logo_colors.is_empty() {
return Ok(auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS)));
let found = auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS));
return Ok((fold_tones(pix, found.clone()), found));
}
let mut palette: Vec<[u8; 3]> = Vec::new();
for s in &params.logo_colors {
@@ -332,7 +336,7 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul
palette.push(c);
}
}
Ok(palette)
Ok((palette.clone(), palette))
}
/// Trace a flat-colour logo.
@@ -342,11 +346,13 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
return Ok(TraceResult::default());
}
let (mut pix, mut w, mut h, mut factor) = working_copy(img, 1);
let mut palette = choose_palette(params, &pix, w, h)?;
// (`tones`: the colours as found, each tone of an unevenly lit colour on its own. Whether the
// image is flat art is asked of them: a paper's tones are flat colour too.)
let (mut palette, tones) = choose_palette(params, &pix, w, h)?;
if palette.is_empty() {
return Ok(TraceResult::default());
}
let fraction = flat_fraction(&pix, w, h, &palette);
let fraction = flat_fraction(&pix, w, h, &tones);
if fraction < MIN_FLAT {
return Err(TraceError::NotFlat { fraction });
}
@@ -366,6 +372,8 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
planes
} else {
palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len()));
// (A colour nothing explained may be one more tone of a colour that is there.)
palette = fold_tones(&pix, palette);
classes = classes_for(&palette);
unmix(&pix, w, h, &classes).0
}
@@ -485,6 +493,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
stroke: None,
shape: None,
gradient: None,
square_caps: false,
});
}
Ok(out)
+14
View File
@@ -413,3 +413,17 @@ fn random_small_images_and_settings_never_panic() {
}
}
}
/// A scan's paper is darker in one corner than in another: it is still one colour, not a band of
/// paths for each tone of it.
#[test]
fn uneven_paper_is_one_colour() {
let img = Raster::from_fn(160, 120, |x, y| {
let shade = 1.0 - 0.1 * f64::from(x) / 160.0 - 0.04 * (f64::from(y) / 40.0).sin().abs();
let ink = (40..120).contains(&x) && (30..90).contains(&y);
let c = if ink { [40.0, 60.0, 150.0] } else { [236.0, 230.0, 214.0] };
[(c[0] * shade) as u8, (c[1] * shade) as u8, (c[2] * shade) as u8, 255]
});
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
assert_eq!(res.paths.len(), 2, "{:?}", res.paths.iter().map(|p| (p.color, p.pixels)).collect::<Vec<_>>());
}
+105 -56
View File
@@ -37,13 +37,18 @@ impl TracedShape {
}
}
/// How near (px) a sharp corner the outline may fall short of it, and by how much more than
/// elsewhere: anti-aliasing and blur take that much off a corner.
const CORNER: f64 = 3.0;
const CORNER_SLACK: f64 = 0.75;
/// How far a point may lie from the shape: half a pixel, and for large shapes 0.2% of their size.
fn tolerance(size: f64) -> f64 {
(0.002 * size).max(0.5)
}
/// The shape the closed outline `sp` is, if it is one (and at least 6 px across).
pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
pub(crate) fn recognise(sp: &SubPath, hard: bool) -> Option<TracedShape> {
if !sp.closed || sp.anchors.len() < 2 {
return None;
}
@@ -83,7 +88,8 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
// The direction of the longer axis (none when the shape is as wide every way).
let axis = if spread < 0.01 * mean { 0.0 } else { 0.5 * (2.0 * sxy).atan2(sxx - syy) };
let size = 4.0 * major.sqrt();
let tol = tolerance(size);
// (An outline without anti-aliasing is only known to about half a pixel.)
let tol = tolerance(size) * if hard { 1.5 } else { 1.0 };
if size < 6.0 || !size.is_finite() {
return None;
}
@@ -110,64 +116,107 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
}
}
// A rectangle, along its longest straight side (a square has no longer axis to go by).
// A rectangle. Its sides run along its longest straight side if it has one, else along the
// longer axis of its area (a blurred one may have no side that is straight), or the pixels.
let quarter = std::f64::consts::FRAC_PI_2;
let folded = |a: f64| a - quarter * (a / quarter).round();
let side = (0..sp.segment_count())
.filter(|&i| sp.segment_is_line(i))
.map(|i| sp.segment(i))
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)));
if let Some(side) = side {
let d = side.p3 - side.p0;
// Turned by under 45° either way: a rectangle is the same a quarter turn on.
let quarter = std::f64::consts::FRAC_PI_2;
let angle = d.y.atan2(d.x);
let angle = angle - quarter * (angle / quarter).round();
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)))
.map(|s| folded((s.p3 - s.p0).y.atan2((s.p3 - s.p0).x)));
for angle in side.into_iter().chain([folded(axis), 0.0]) {
let xy: Vec<(f64, f64)> = pts.iter().map(|&p| local(p, angle)).collect();
let (hw, hh) = xy.iter().fold((0.0f64, 0.0f64), |m, p| (m.0.max(p.0.abs()), m.1.max(p.1.abs())));
// Square corners first; else rounded ones, which take (4 − π) r² off the area.
let rounded = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt().min(hw.min(hh));
for radius in [0.0, rounded] {
let off = xy.iter().map(|&(x, y)| {
// Distance to the rounded rectangle's edge.
// (Its half sizes: where its sides lie on average, not where their farthest points do.)
let side = |far: f64, upright: bool, other: f64| {
let on: Vec<f64> = xy
.iter()
.map(|p| if upright { (p.0, p.1) } else { (p.1, p.0) })
.filter(|&(a, b)| a.abs() >= far - 0.5 && b.abs() <= other - CORNER)
.map(|(a, _)| a.abs())
.collect();
if on.len() >= 4 { on.iter().sum::<f64>() / on.len() as f64 } else { far }
};
let (hw, hh) = (side(hw, true, hh), side(hh, false, hw));
if hw <= 2.0 || hh <= 2.0 {
continue;
}
// How far the outline is from the rectangle with corners of `radius`, at worst; the points
// by its corners `slack` nearer than they are.
let off = |radius: f64, slack: f64| {
let each = xy.iter().map(|&(x, y)| {
let (qx, qy) = (x.abs() - hw + radius, y.abs() - hh + radius);
(qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs()
let d = (qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs();
if x.abs() > hw - CORNER && y.abs() > hh - CORNER { (d - slack).max(0.0) } else { d }
});
if hw > 2.0 && hh > 2.0 && (radius == 0.0 || radius >= 0.75) && off.fold(0.0, f64::max) <= tol {
return Some(TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() });
}
each.fold(0.0, f64::max)
};
let rectangle = |radius: f64| TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() };
// Square corners (anti-aliasing and blur take a little off a corner: that is no radius).
// (Not of a thin bar: it is all corners, and would pass for a rectangle whatever it is.)
if off(0.0, if hw.min(hh) >= 2.0 * CORNER { CORNER_SLACK } else { 0.0 }) <= tol {
return Some(rectangle(0.0));
}
// Rounded ones: the radius that fits best, near the one the area says ((4 − π) r² is missing).
let by_area = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt();
let radii = (0..=40).map(|k| by_area + (f64::from(k) - 20.0) * 0.15).filter(|r| (CORNER..=hw.min(hh)).contains(r));
if let Some((_, radius)) = radii.map(|r| (off(r, 0.0), r)).min_by(|a, b| a.0.total_cmp(&b.0)).filter(|best| best.0 <= tol) {
return Some(rectangle(radius));
}
}
// A regular polygon: its corners evenly round the middle, its sides straight between them.
let sides = sp.anchors.len();
if (3..=12).contains(&sides) {
let radius = sp.anchors.iter().map(|a| a.p.distance(c)).sum::<f64>() / sides as f64;
let first = sp.anchors.first()?.p - c;
let rotation = (first.y.atan2(first.x).to_degrees() + 90.0).rem_euclid(360.0 / sides as f64);
let shape = TracedShape::Polygon { center: c, radius, sides: sides as u32, rotation };
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
// Every corner of the polygon has one of the outline's on it, and every point of the
// outline lies on one of the polygon's sides.
let cornered = ideal.iter().all(|q| sp.anchors.iter().any(|a| q.distance(a.p) <= 1.5 * tol));
let on_a_side = |p: Point| {
(0..ideal.len()).any(|i| {
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
p.distance(a + (b - a) * t) <= tol
})
// A regular polygon: going round it, its outline is nearest its middle once a side and farthest
// once a corner. The sides are as many as that happens most strongly for, the corners where
// it is farthest, the size what the area says.
let polar: Vec<(f64, f64)> = pts.iter().map(|&p| ((p - c).hypot(), (p - c).y.atan2((p - c).x))).collect();
let weights: Vec<f64> = (0..n).map(|i| (polar[(i + 1) % n].1 - polar[i].1).sin().abs()).collect();
let mean = polar.iter().zip(&weights).map(|(p, w)| p.0 * w).sum::<f64>() / weights.iter().sum::<f64>().max(1e-9);
let wave = |sides: u32| {
polar.iter().zip(&weights).fold((0.0, 0.0), |m, (p, w)| {
(m.0 + (p.0 - mean) * w * (f64::from(sides) * p.1).cos(), m.1 + (p.0 - mean) * w * (f64::from(sides) * p.1).sin())
})
};
let sides = (3..=12u32).max_by(|&a, &b| wave(a).0.hypot(wave(a).1).total_cmp(&wave(b).0.hypot(wave(b).1)))?;
let (re, im) = wave(sides);
let corner = im.atan2(re) / f64::from(sides);
// (More exactly: by the outline's own corners, where it has them there.)
let step = 2.0 * std::f64::consts::PI / f64::from(sides);
let turns: Vec<f64> = sp
.anchors
.iter()
.map(|a| ((a.p - c).y.atan2((a.p - c).x) - corner, (a.p - c).hypot()))
.map(|(d, far)| (d - step * (d / step).round(), far))
.filter(|&(d, far)| (d * far).abs() <= CORNER && far > 0.9 * mean)
.map(|(d, _)| d)
.collect();
let corner = corner + turns.iter().sum::<f64>() / turns.len().max(1) as f64;
let radius = (2.0 * area / (f64::from(sides) * (2.0 * std::f64::consts::PI / f64::from(sides)).sin())).sqrt();
let rotation = (corner.to_degrees() + 90.0).rem_euclid(360.0 / f64::from(sides));
let shape = TracedShape::Polygon { center: c, radius, sides, rotation };
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
// Every point of the outline lies on one of the polygon's sides (by its corners, nearly).
let side_length = 2.0 * radius * (std::f64::consts::PI / f64::from(sides)).sin();
let on_a_side = |p: Point| {
let slack = if side_length >= 4.0 * CORNER && ideal.iter().any(|q| q.distance(p) <= CORNER) { CORNER_SLACK } else { 0.0 };
(0..ideal.len()).any(|i| {
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
p.distance(a + (b - a) * t) <= tol + slack
})
};
if radius > 3.0 && pts.iter().all(|&p| on_a_side(p)) {
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
let side = radius * std::f64::consts::SQRT_2;
let turned = (rotation + 45.0).rem_euclid(90.0);
let square = TracedShape::Rectangle {
center: c,
width: side,
height: side,
radius: 0.0,
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
};
if cornered && pts.iter().all(|&p| on_a_side(p)) {
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
let side = radius * std::f64::consts::SQRT_2;
let turned = (rotation + 45.0).rem_euclid(90.0);
let square = TracedShape::Rectangle {
center: c,
width: side,
height: side,
radius: 0.0,
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
};
return Some(if sides == 4 { square } else { shape });
}
return Some(if sides == 4 { square } else { shape });
}
None
}
@@ -177,7 +226,7 @@ mod tests {
use super::*;
fn is(shape: TracedShape) -> Option<TracedShape> {
recognise(shape.path().subpaths.first().unwrap())
recognise(shape.path().subpaths.first().unwrap(), false)
}
#[test]
@@ -221,20 +270,20 @@ mod tests {
fn what_only_resembles_a_shape_is_none() {
// A star, an L, a rectangle with one corner cut, an egg.
let star = shapes::star(Point::new(50.0, 50.0), 30.0, 12.0, 5, 0.0);
assert_eq!(recognise(&star.subpaths[0]), None);
assert_eq!(recognise(&star.subpaths[0], false), None);
let poly = |pts: &[(f64, f64)]| SubPath::polyline(&pts.iter().map(|&p| Point::from(p)).collect::<Vec<_>>(), true);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)])), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)])), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)]), false), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)]), false), None);
let mut egg = shapes::ellipse(Rect::new(10.0, 10.0, 70.0, 50.0));
egg.subpaths[0].anchors[2].p.x += 4.0;
assert_eq!(recognise(&egg.subpaths[0]), None);
assert_eq!(recognise(&egg.subpaths[0], false), None);
// Too small to say, open, or not a number.
assert_eq!(is(TracedShape::Ellipse { center: Point::new(5.0, 5.0), rx: 2.0, ry: 2.0, rotation: 0.0 }), None);
let mut open = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
open.closed = false;
assert_eq!(recognise(&open), None);
assert_eq!(recognise(&open, false), None);
let mut bad = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
bad.anchors[1].p.x = f64::NAN;
assert_eq!(recognise(&bad), None);
assert_eq!(recognise(&bad, false), None);
}
}
+45
View File
@@ -1250,3 +1250,48 @@ fn a_hard_edged_circle_is_a_circle() {
let [TracedPath { shape: Some(TracedShape::Ellipse { center, rx, ry, .. }), .. }] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
assert!(rx == ry && (rx - 30.0).abs() < 0.3 && center.distance(Point::new(50.0, 50.0)) < 0.1, "{center:?} {rx} {ry}");
}
/// Levelling moves no end far: a long line two degrees off level stays where the image has it.
#[test]
fn a_long_line_two_degrees_askew_is_not_levelled() {
let slope = 2f64.to_radians().tan();
let img = Raster::from_fn(240, 40, |x, y| {
let top = 12.0 + slope * (f64::from(x) - 20.0);
let inside = (20..220).contains(&x) && (f64::from(y) + 0.5 - top - 4.0).abs() <= 4.0;
if inside { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
});
let res = trace(&img, &TraceParams { ignore_white: true, snap_curves_to_lines: true, ..Default::default() });
let b = res.paths[0].path.bounds().unwrap();
// 200 px at 2° rises 7 px: levelled, the bar would be 8 px tall, not 15.
assert!(b.height() > 13.0, "{b:?}");
}
/// Create Strokes: a line cut square at its ends is stroked with square caps, a line with round
/// ends with round ones, and either ends where its ink does.
#[test]
fn stroke_caps_are_square_or_round_as_the_ink_is() {
let line = |square: bool| {
let img = Raster::from_fn(140, 40, |x, y| {
// Anti-aliased by 4 × 4 samples: a bar 8 px thick from x = 20 to 110.
let mut inside = 0;
for sy in 0..4 {
for sx in 0..4 {
let (px, py) = (f64::from(x) + (f64::from(sx) + 0.5) / 4.0, f64::from(y) + (f64::from(sy) + 0.5) / 4.0);
let d = if square { (py - 20.3).abs().max((px - 65.0).abs() - 41.0) } else { (px.clamp(24.0, 106.0) - px).hypot(py - 20.3) };
inside += u32::from(d <= 4.0);
}
}
let v = (255 - inside * 255 / 16) as u8;
[v, v, v, 255]
});
let res = trace(&img, &crate::preset("Outline").unwrap());
let [only] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
let (a, b) = (only.path.subpaths[0].anchors[0].p, only.path.subpaths[0].anchors.last().unwrap().p);
// Either cap goes half the width past the path's end: the path runs from 24 to 106.
assert!((a.x.min(b.x) - 24.0).abs() < 0.6 && (a.x.max(b.x) - 106.0).abs() < 0.6, "{a:?} {b:?}");
assert!((only.stroke.unwrap() - 8.0).abs() < 0.4);
only.square_caps
};
assert!(line(true));
assert!(!line(false));
}
+32 -4
View File
@@ -26,9 +26,12 @@ pub enum Class {
Tone,
/// Linear gradients whose line and stops are known: one over the whole image, one in a disc.
Ramp,
/// A radial gradient whose middle and stops are known.
Round,
}
pub const CLASSES: [Class; 8] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone, Class::Ramp];
pub const CLASSES: [Class; 9] =
[Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone, Class::Ramp, Class::Round];
impl Class {
pub fn name(self) -> &'static str {
@@ -41,6 +44,7 @@ impl Class {
Class::Flat => "flat colour art",
Class::Tone => "gradient, photo",
Class::Ramp => "linear gradients",
Class::Round => "radial gradient",
}
}
}
@@ -97,9 +101,11 @@ pub struct Input {
tone: Option<Tone>,
}
/// A linear gradient: its line (256-unit coordinates) and its stops along it (0 to 1).
/// A gradient: its line (256-unit coordinates) and its stops along it (0 to 1); or, radial, its
/// middle, a point on its outermost circle, and its stops outwards.
#[derive(Clone)]
pub struct Ramp {
pub radial: bool,
pub start: Point,
pub end: Point,
pub stops: Vec<(f64, [f64; 3])>,
@@ -108,7 +114,7 @@ pub struct Ramp {
impl Ramp {
pub fn color_at(&self, p: Point) -> [f64; 3] {
let d = self.end - self.start;
let t = ((p - self.start).dot(d) / d.hypot2()).clamp(0.0, 1.0);
let t = if self.radial { (p - self.start).hypot() / d.hypot() } else { (p - self.start).dot(d) / d.hypot2() }.clamp(0.0, 1.0);
let after = self.stops.iter().position(|s| s.0 >= t).unwrap().max(1);
let (a, b) = (self.stops[after - 1], self.stops[after]);
let k = (t - a.0) / (b.0 - a.0);
@@ -121,7 +127,12 @@ fn two_stop_ramp() -> Ramp {
let dir = kurbo::Vec2::from_angle(25f64.to_radians());
let reach = kurbo::Vec2::new(128.0, 128.0).dot(dir);
let centre = Point::new(128.0, 128.0);
Ramp { start: centre - dir * reach, end: centre + dir * reach, stops: vec![(0.0, [24.0, 40.0, 150.0]), (1.0, [250.0, 215.0, 60.0])] }
Ramp {
radial: false,
start: centre - dir * reach,
end: centre + dir * reach,
stops: vec![(0.0, [24.0, 40.0, 150.0]), (1.0, [250.0, 215.0, 60.0])],
}
}
/// Red through white to green across a disc, 110° from level.
@@ -129,12 +140,28 @@ fn three_stop_ramp() -> Ramp {
let dir = kurbo::Vec2::from_angle(110f64.to_radians());
let centre = Point::new(128.0, 128.0);
Ramp {
radial: false,
start: centre - dir * DISC,
end: centre + dir * DISC,
stops: vec![(0.0, [200.0, 30.0, 40.0]), (0.4, [245.0, 240.0, 230.0]), (1.0, [20.0, 120.0, 70.0])],
}
}
/// Pale yellow at (100, 150) out to deep blue 200 units away, over the whole image.
fn radial_ramp() -> Ramp {
let middle = Point::new(100.0, 150.0);
Ramp {
radial: true,
start: middle,
end: middle + kurbo::Vec2::new(200.0, 0.0),
stops: vec![(0.0, [250.0, 240.0, 150.0]), (1.0, [20.0, 40.0, 130.0])],
}
}
fn radial(u: f64, v: f64, _: u32, _: u32) -> [f64; 3] {
radial_ramp().color_at(Point::new(u * 256.0, v * 256.0))
}
/// The radius of the disc the three-stop gradient fills.
const DISC: f64 = 90.0;
@@ -364,6 +391,7 @@ pub fn inputs() -> Vec<Input> {
tone("photo-like", photo),
Input { class: Class::Ramp, ramp: Some(two_stop_ramp()), ..tone("two-stop gradient", two_stop) },
Input { class: Class::Ramp, ramp: Some(three_stop_ramp()), ..tone("three-stop gradient in a disc", three_stop) },
Input { class: Class::Round, ramp: Some(radial_ramp()), ..tone("radial gradient", radial) },
];
if let Some(input) = all.iter_mut().find(|i| i.name == "plain shapes") {
input.plain = plain;
+12 -4
View File
@@ -64,6 +64,7 @@ static ALLOC: Counting = Counting;
/// Whether `input` is what `preset` is meant for.
fn target(preset: &str, input: &Input) -> bool {
let preset = preset.strip_suffix(", enhanced").unwrap_or(preset);
let classes: &[Class] = match preset {
"High Fidelity Photo"
| "Low Fidelity Photo"
@@ -74,7 +75,7 @@ fn target(preset: &str, input: &Input) -> bool {
"3 Colors" => return input.name == "two touching halves",
"6 Colors" | "16 Colors" | "Flat Logo" | "Auto Color" | "6 Colors with Auto Grouping" => &[Class::Flat],
"Default with Shapes" | "6 Colors with Shapes" => &[Class::Plain],
name if name.ends_with("Gradients") => &[Class::Ramp],
name if name.ends_with("Gradients") => &[Class::Ramp, Class::Round],
"Sketched Art" => &[Class::Shape, Class::Lines],
"Silhouettes" => &[Class::Shape],
"Line Art" | "Outline" => &[Class::Lines],
@@ -147,6 +148,7 @@ fn svg_trace(svg: &str) -> TraceResult {
stroke: None,
shape: None,
gradient: None,
square_caps: false,
})
});
TraceResult { paths: paths.collect(), palette: vec![[0; 3]], ..Default::default() }
@@ -185,12 +187,12 @@ fn stat(runs: &[&Metrics], field: impl Fn(&Metrics) -> f64, max: bool, digits: u
if x.is_finite() { format!("{:.digits$}", x + 0.0) } else { "all".into() }
}
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | grouped % | gradient paths | found % | angle off | ramp off |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | false shapes | grouped % | gradient paths | found % | angle off | ramp off |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
fn row(runs: &[&Run]) -> String {
let m: Vec<&Metrics> = runs.iter().filter_map(|r| r.metrics.as_ref()).collect();
if m.is_empty() {
return format!("| refused |{}", " |".repeat(26));
return format!("| refused |{}", " |".repeat(27));
}
let cells = [
stat(&m, |m| m.delta, false, 2),
@@ -215,6 +217,7 @@ fn row(runs: &[&Run]) -> String {
stat(&m, |m| m.width_off, false, 2),
stat(&m, |m| m.stroke_anchors, false, 1),
stat(&m, |m| m.shapes, false, 0),
stat(&m, |m| m.false_shapes, false, 2),
stat(&m, |m| m.grouped, false, 0),
stat(&m, |m| m.gradients, false, 1),
stat(&m, |m| m.ramp_found, false, 0),
@@ -264,6 +267,11 @@ fn report() -> String {
] {
presets.push((name, TraceParams { gradients, ..vectorcraft_trace::preset(base).unwrap() }));
}
// Each preset's enhanced version, on that preset's own inputs.
for name in vectorcraft_trace::PRESET_NAMES {
let enhanced: &'static str = Box::leak(format!("{name}, enhanced").into_boxed_str());
presets.push((enhanced, vectorcraft_trace::enhanced_preset(name).unwrap()));
}
let inputs = inputs();
let drawn = draw_all(&inputs);
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew | Class::Plain);
@@ -351,7 +359,7 @@ fn quality_report() {
let inputs = inputs();
for (name, _) in presets().iter().filter(|(_, p)| p.mode != vectorcraft_trace::Mode::BlackAndWhite && p.mode != vectorcraft_trace::Mode::Logo) {
let params = TraceParams { gradients: true, ..vectorcraft_trace::preset(name).unwrap() };
for (input, size, variant, img) in draw_all(&inputs).iter().filter(|d| !matches!(d.0.class, Class::Tone | Class::Ramp)) {
for (input, size, variant, img) in draw_all(&inputs).iter().filter(|d| !matches!(d.0.class, Class::Tone | Class::Ramp | Class::Round)) {
let found = trace_within(img, &params, MAX_ANCHORS).map_or(0, |res| res.paths.iter().filter(|p| p.gradient.is_some()).count());
assert_eq!(found, 0, "{name} with Gradients on {} at {size} px, {variant:?}", input.name);
}
+22 -4
View File
@@ -59,7 +59,10 @@ pub fn fidelity(src: &Raster, out: &Raster) -> (f64, f64) {
fn painted(tp: &TracedPath) -> BezPath {
let bez = tp.path.to_bezpath();
match tp.stroke {
Some(width) => kurbo::stroke(bez, &Stroke::new(width), &StrokeOpts::default(), 0.05),
Some(width) => {
let cap = if tp.square_caps { kurbo::Cap::Square } else { kurbo::Cap::Round };
kurbo::stroke(bez, &Stroke::new(width).with_caps(cap), &StrokeOpts::default(), 0.05)
}
None => bez,
}
}
@@ -194,6 +197,8 @@ pub struct Metrics {
pub stroke_anchors: f64,
/// Share (%) of the art's plain shapes that came out as a shape lying within 0.6 px of it.
pub shapes: f64,
/// Shapes: paths made a shape that lie more than 0.6 px off the art.
pub false_shapes: f64,
/// Auto Grouping: share (%) of the paths that lie inside another.
pub grouped: f64,
/// Gradients: paths filled with one; and where the input is a known gradient, whether one was
@@ -242,12 +247,19 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
let (mut ramp_found, mut ramp_angle, mut ramp_off) = (f64::NAN, f64::NAN, f64::NAN);
if let Some(ramp) = &input.ramp {
let k = f64::from(size) / 256.0;
let found = res.paths.iter().filter(|tp| tp.gradient.is_some()).max_by_key(|tp| tp.pixels).and_then(|tp| tp.gradient.as_ref());
// (Of the input's kind: a linear gradient found in a radial one is not it.)
let found = res
.paths
.iter()
.filter(|tp| tp.gradient.as_ref().is_some_and(|g| g.radial == ramp.radial))
.max_by_key(|tp| tp.pixels)
.and_then(|tp| tp.gradient.as_ref());
ramp_found = if found.is_some() { 100.0 } else { 0.0 };
if let Some(g) = found {
let (truth, own) = (ramp.end - ramp.start, g.end - g.start);
let between = (truth.atan2() - own.atan2()).to_degrees().rem_euclid(180.0);
ramp_angle = between.min(180.0 - between);
// (A radial gradient has no direction: its middle shows in the colours along the line.)
ramp_angle = if ramp.radial { f64::NAN } else { between.min(180.0 - between) };
ramp_off = (0..=16)
.map(|j| {
let p = ramp.start + truth * (f64::from(j) / 16.0);
@@ -271,11 +283,16 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
let nan = f64::NAN;
let (mut geo_mean, mut geo_max, mut miss_max, mut corners, mut kinks, mut slivers, mut sliver_area) = (nan, nan, nan, nan, nan, nan, nan);
if !matches!(input.class, Class::Tone | Class::Ramp) {
let mut false_shapes = if params.shapes { 0.0 } else { nan };
if !matches!(input.class, Class::Tone | Class::Ramp | Class::Round) {
let truth_shapes = input.scaled(size);
let truth = Outline::new(truth_shapes.iter().map(|(p, _)| p), None);
let traced = Outline::new(shapes.iter().map(|(p, _)| p), Some(f64::from(size)));
(geo_mean, geo_max) = traced.distance_to(&truth);
// Shapes that are not the art's: a path made a shape that strays more than 0.6 px from it.
false_shapes =
res.paths.iter().filter(|tp| tp.shape.is_some() && Outline::new([&tp.path.to_bezpath()], None).distance_to(&truth).1 > 0.6).count()
as f64;
miss_max = truth.distance_to(&traced).1;
let true_corners = input.scaled_corners(size);
@@ -378,6 +395,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
ramp_angle,
ramp_off,
shapes: shapes_found,
false_shapes,
centre_off,
width_off,
stroke_anchors,
+1
View File
@@ -1428,6 +1428,7 @@ lighting Light Světlo
Black and White Černobílé
Corners Rohy
Auto Grouping Automatické seskupení
Enhanced Vylepšené
Ignore Color Ignorovat barvu
Ignore White Ignorovat bílou
Palette: Paleta:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1230,6 +1230,7 @@
Ignore Ignorieren
Ignore Artboards (all the art as one page) Zeichenflächen ignorieren (gesamte Grafik als eine Seite)
Auto Grouping Automatisch gruppieren
Enhanced Erweitert
Ignore Color Farbe ignorieren
Ignore White Weiß ignorieren
Palette: Palette:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1248,6 +1248,7 @@
Ignore Ignorar
Ignore Artboards (all the art as one page) Ignorar mesas de trabajo (toda la ilustración como una página)
Auto Grouping Agrupación automática
Enhanced Mejorado
Ignore Color Ignorar color
Ignore White Ignorar blanco
Palette: Paleta:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1247,6 +1247,7 @@
Ignore Ignorer
Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page)
Auto Grouping Groupement automatique
Enhanced Amélioré
Ignore Color Ignorer la couleur
Ignore White Ignorer le blanc
Palette: Palette :
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1612,6 +1612,7 @@
Ignore Kihagy
Ignore Artboards (all the art as one page) Rajztáblák figyelmen kívül hagyása (az összes grafika egy oldalon)
Auto Grouping Automatikus csoportosítás
Enhanced Továbbfejlesztett
Ignore Color Szín figyelmen kívül hagyása
Ignore White Fehér figyelmen kívül hagyása
Image Kép
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1247,6 +1247,7 @@
Ignore Ignora
Ignore Artboards (all the art as one page) Ignora tavole da disegno (tutta la grafica come una sola pagina)
Auto Grouping Raggruppamento automatico
Enhanced Avanzato
Ignore Color Ignora colore
Ignore White Ignora bianco
Palette: Palette:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1485,6 +1485,7 @@
Ignore 無視
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
Auto Grouping 自動グループ化
Enhanced 拡張
Ignore Color カラーを無視
Ignore White 白を無視
Palette: パレット:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1480,6 +1480,7 @@
Ignore 무시
Ignore Artboards (all the art as one page) 아트보드 무시 (모든 아트웍을 한 페이지로)
Auto Grouping 자동 그룹화
Enhanced 향상됨
Ignore Color 색상 무시
Ignore White 흰색 무시
Palette: 팔레트:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1378,6 +1378,7 @@ lighting Light Światło
Black and White Czarno-biały
Corners Narożniki
Auto Grouping Automatyczne grupowanie
Enhanced Ulepszone
Ignore Color Ignoruj kolor
Ignore White Ignoruj biel
Palette: Paleta:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1245,6 +1245,7 @@
Ignore Игнорировать
Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница)
Auto Grouping Автогруппировка
Enhanced Улучшенный
Ignore Color Игнорировать цвет
Ignore White Игнорировать белый
Palette: Палитра:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1241,6 +1241,7 @@
Ignore Ігнорувати
Ignore Artboards (all the art as one page) Ігнорувати монтажні області (весь малюнок як одна сторінка)
Auto Grouping Автогрупування
Enhanced Покращений
Ignore Color Ігнорувати колір
Ignore White Ігнорувати білий
Palette: Палітра:
Can't render this file because it contains an unexpected character in line 660 and column 40.
+1
View File
@@ -1441,6 +1441,7 @@
Ignore Bỏ qua
Ignore Artboards (all the art as one page) Bỏ qua bảng vẽ (toàn bộ bản vẽ thành một trang)
Auto Grouping Tự động nhóm
Enhanced Nâng cao
Ignore Color Bỏ qua màu
Ignore White Bỏ qua màu trắng
Image Hình ảnh
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1441
1442
1443
1444
1445
1446
1447
+1
View File
@@ -1193,6 +1193,7 @@
Ignore 忽略
Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页)
Auto Grouping 自动编组
Enhanced 增强
Ignore Color 忽略颜色
Ignore White 忽略白色
Palette: 调板:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1223,6 +1223,7 @@
Ignore 略過
Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面)
Auto Grouping 自動群組
Enhanced 增強
Ignore Color 忽略顏色
Ignore White 忽略白色
Palette: 調色盤:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19
View File
@@ -68,6 +68,15 @@ struct TraceUi {
synced: Value,
}
/// The parameters of each preset's enhanced version, by the preset's name.
fn enhanced_presets(app: &mut VectorcraftApp) -> Vec<(String, Value)> {
let v = app.session.execute("imageTrace.presets", &json!({})).unwrap_or_default();
v["presets"]
.as_array()
.map(|a| a.iter().map(|p| (p["name"].as_str().unwrap_or("").to_string(), p["enhanced"].clone())).collect())
.unwrap_or_default()
}
fn presets(app: &mut VectorcraftApp) -> Vec<(String, Value)> {
let v = app.session.execute("imageTrace.presets", &json!({})).unwrap_or_default();
v["presets"]
@@ -242,6 +251,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
retrace = true;
}
});
// The preset's enhanced version (with Shapes and Auto Grouping) or its legacy one.
let better = enhanced_presets(app).into_iter().find(|p| p.0 == st.preset).map(|p| p.1).filter(|p| !p.is_null());
let legacy = all.iter().find(|p| p.0 == st.preset).map(|p| p.1.clone());
let on = better.as_ref() == Some(&st.params);
if let (Some(better), Some(legacy)) = (better, legacy)
&& widgets::check(ui, tl!("Enhanced"), on, true)
{
st.params = if on { legacy } else { better };
retrace = true;
}
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("View:"));
if let Some(v) = view_dropdown(ui, st.view, 170.0) {
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@@ -60,7 +60,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); initial Revolve, Extrude & Bevel, Inflate and Rotate; SVG Filters, remaining raster effects and 3D materials, lights and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
@@ -80,7 +80,6 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | M12 row: Image Trace has 15 presets and the Ignore Color, Shapes, Auto Grouping and Gradients options (#1574, #1609, #1618, #1627); its speed figure, measured before these, is dropped for the measurements in `image-trace-quality.md` |
| 2026-10-13 | minor | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498); Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542); Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539); Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536); the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); estimates unchanged |
| 2026-10-13 | minor | Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508); commands and MCP `add_text` report area type that does not fit its frame (#1471), a tab stop in the Tabs panel drags from where the press landed (#1458); M9: 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517), `vectorcraft-cli perf` reports load and memory on Windows (#1502); sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), French catalog complete again (#1511), a Hungarian message in the informal address (#1474); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged |
| 2026-10-13 | minor | M6: Split Into Grid has its documented options (#1462), the Pathfinder panel works on a group selected alone (#1450); M2: Transform Objects in the Transform dialogs and Transform Each, Reflect across an angle (#1448, #1460); M4: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434), SVG knockout groups without masks (#1435); M7: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); M13: commands refuse a parameter key they don't take (#1442); M5: edits and Alt-drags of thousands of objects (#1429 in part); M14: a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part), a window surface past 8192 px no longer closes the app (#1459 in part); estimates unchanged |
+2 -3
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: docLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user scoreLine truncated
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -468,7 +468,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
- **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, Flat Logo shatters scans, stroke junctions stay at their skeleton pixels, blurred, noisy and scanned gradients are found in part or in pieces, and radial gradients stay bands.
- **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Still weak, by the same numbers: abutting paths leave 22 to 50 px of gaps (each region's edge is still fitted on its own), thin lines in blurred and scanned images take several times the art's anchors, hairlines under half a pixel are lost to the threshold, a scan's uneven paper splits into tones when more colours are asked for than the art has, and a 16 megapixel photo takes about 4 GB while it is traced.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
@@ -528,7 +528,6 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Image Trace, measured in `image-trace-quality.md`: shared edges fitted once (#1535), 16 megapixel photos in 0.9 GB (#1546), thin lines (#1563), Ignore Color and two presets (#1574), upright snapping (#1593), stroked centre lines (#1595), corners and Shapes (#1609), Auto Grouping (#1618), Gradients (#1627). Score and hours unchanged (estimated) |
| 2026-10-13 | minor | Files: Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Rendering: Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). Localization: The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498) (4,510 rows); scores and hours unchanged (estimated) |
| 2026-10-13 | minor | Tools and UI: Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Correction: the combined Average and Join with its corner or smooth choice was listed as done in [ui-parity.md](ui-parity.md) and does not exist in the app (`path.join` and `path.average` are separate commands, and Join has no such choice); it is now listed as missing there and in [gap 1](gaps.md); scores and hours unchanged (estimated) |
| 2026-10-13 | minor | UI and tools: sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483); in Outline view a template layer stays in Preview, dimmed (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1500, fixes #1471); graphs: `graph.setData`'s CSV keeps each value exactly (#1485); scores and hours unchanged (estimated) |