Merge branch 'pr719' into land/719

This commit is contained in:
bflatastic 2026-10-09 15:19:04 +07:00
commit 58b95b51ef
24 changed files with 3098 additions and 39 deletions

View File

@ -148,6 +148,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) 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, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian 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, Spanish, French, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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; 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.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **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; 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 or an image over 64 megapixels is refused with advice rather than running out of memory, 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 (`object.cropImage {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):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
@ -188,7 +189,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| 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 (artboard panel/tool, 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 (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker 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, regenerate in place) done; graph Design/Column/Marker 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) | 🟡 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); 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 |

View File

@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
"{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, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), 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)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),

View File

@ -750,14 +750,61 @@ fn image_trace_presets_query_and_errors() {
let mut s = session();
let r = s.execute("imageTrace.presets", &json!({})).unwrap();
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(names.len(), 12);
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray"));
assert_eq!(names.len(), 13);
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray") && names.contains(&"Flat Logo"));
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image");
assert!(find_command("imageTrace.make").unwrap().menu == ["Object", "Image Trace"]);
assert!(find_command("livePaint.make").unwrap().menu == ["Object", "Live Paint"]);
}
/// A soft-edged red disc (r = 30 px at (50.3, 49.6)) on transparency, placed at (50, 50) scaled ×2.
fn soft_logo_doc(s: &mut Session) -> NodeId {
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let d = (x as f64 + 0.5 - 50.3).hypot(y as f64 + 0.5 - 49.6);
let cover = (0.5 + (30.0 - d) * 0.7).clamp(0.0, 1.0);
[200, 40, 60, (cover * 255.0).round() as u8]
});
add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0))
}
#[test]
fn image_trace_flat_logo_preset_makes_a_true_circle() {
let mut s = session();
soft_logo_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"preset": "Flat Logo"})).unwrap();
assert_eq!((r["paths"].as_u64(), r["colors"].as_u64()), (Some(1), Some(1)));
assert!(r["anchors"].as_u64().unwrap() <= 6, "a circle is four anchors: {r}");
let g = NodeId(r["id"].as_u64().unwrap());
let n = node(&s, g);
let t = n.trace.clone().expect("settings stored");
assert_eq!((t["preset"].as_str(), t["params"]["mode"].as_str()), (Some("Flat Logo"), Some("logo")));
let paths = traced_paths(&n);
let (p, rule) = outline(&paths[0]);
let want = std::f64::consts::PI * 60.0 * 60.0;
let got = vectorcraft_pathops::area(&p, rule);
assert!((got - want).abs() / want < 0.01, "area {got} vs {want}");
let bb = p.bounds().unwrap();
// The disc is at (50.3, 49.6) in a x2 image placed at (50, 50): centre (150.6, 149.2).
assert!((bb.center().x - 150.6).abs() < 0.3 && (bb.center().y - 149.2).abs() < 0.3, "{bb:?}");
}
#[test]
fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() {
let mut s = session();
let grad = vectorcraft_trace::Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]);
add_image(&mut s, &grad, Affine::IDENTITY);
let e = s.execute("imageTrace.make", &json!({"preset": "Flat Logo"})).unwrap_err().to_string();
assert!(e.contains("flat-colour") && e.contains("Color mode"), "{e}");
let mut s = session();
soft_logo_doc(&mut s);
let e = s.execute("imageTrace.make", &json!({"preset": "Flat Logo", "params": {"logoColors": ["crimson"]}})).unwrap_err().to_string();
assert!(e.contains("#rrggbb"), "{e}");
// The named colours are used as given.
let r = s.execute("imageTrace.makeAndExpand", &json!({"preset": "Flat Logo", "params": {"logoColors": ["#c8283c"]}})).unwrap();
assert_eq!(r["colors"], 1);
}
#[test]
fn image_trace_object_remembers_its_settings() {
let mut s = session();

View File

@ -294,9 +294,10 @@ with the {effect} effect.
Method:
1. open_file the image: it becomes a document of its own pixel size.
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale,
color (palette limited, fullTone for photos, automatic, or documentLibrary to trace with
the document's swatches or a swatch `library`), then threshold, colors (colorDetail for
the document's swatches or a swatch `library`) or logo (flat-colour logos only; the Flat Logo
preset; colours via logoColors), then threshold, colors (colorDetail for
fullTone and automatic), paths, corners, noise, method abutting or overlapping,
ignoreWhite, snapCurvesToLines; for line art, strokes: true (with strokeWidth, the widest
line in pixels) traces lines as stroked centre lines instead of filled outlines. Run

View File

@ -32,6 +32,7 @@
mod centerline;
mod contour;
mod fit;
mod logo;
mod mosaic;
mod quantize;
@ -53,6 +54,10 @@ pub enum TraceError {
Empty,
#[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")]
TooLarge { width: u32, height: u32, max: u64 },
#[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())]
NotFlat { fraction: f64 },
#[error("\"{0}\" is not a colour: write it as #rrggbb")]
BadPalette(String),
#[error(
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
)]
@ -127,6 +132,9 @@ pub enum Mode {
Grayscale,
#[serde(alias = "Color")]
Color,
/// Flat-colour logos: sub-pixel edges, sharp corners restored, true lines and circles.
#[serde(alias = "Logo", alias = "flat", alias = "flatLogo")]
Logo,
}
/// How colour layers relate (Advanced → Method).
@ -187,12 +195,14 @@ pub struct TraceParams {
pub paths: f64,
/// Corners 0–100: higher keeps more corners.
pub corners: f64,
/// Noise: areas smaller than this many pixels are ignored.
/// Noise: areas smaller than this many pixels are ignored. (Flat Logo mode: areas smaller than
/// `noise / 10` pixels of the source image.)
pub noise: u32,
pub method: Method,
/// Drop white areas (no white background shape).
pub ignore_white: bool,
/// Replace nearly-straight curves with straight lines.
/// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs
/// to true circles.
pub snap_curves_to_lines: bool,
/// Create: trace areas as filled shapes.
pub fills: bool,
@ -200,6 +210,10 @@ pub struct TraceParams {
pub strokes: bool,
/// Stroke: the widest feature (px) traced as a stroke.
pub stroke_width: f64,
/// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is
/// always traced on its own and cannot be named, colours closer than about 12 (RGB distance)
/// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.)
pub logo_colors: Vec<String>,
}
impl Default for TraceParams {
@ -222,6 +236,7 @@ impl Default for TraceParams {
fills: true,
strokes: false,
stroke_width: 10.0,
logo_colors: Vec::new(),
}
}
}
@ -258,6 +273,7 @@ pub const PRESET_NAMES: &[&str] = &[
"Silhouettes",
"Line Art",
"Technical Drawing",
"Flat Logo",
];
/// A built-in preset by name (case-insensitive; `[Default]` is accepted).
@ -296,6 +312,8 @@ pub fn preset(name: &str) -> Option<TraceParams> {
"silhouettes" => bw(200, 40.0, 60.0, 30, false),
"line art" => bw(128, 80.0, 70.0, 10, false),
"technical drawing" => bw(128, 95.0, 90.0, 4, true),
// Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel.
"flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d },
_ => return None,
})
}
@ -337,7 +355,8 @@ fn is_white(c: [u8; 3]) -> bool {
c.iter().all(|&v| v >= 245)
}
/// Trace `img` with `params`.
/// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use
/// [`trace_within`] to see why.
pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
// No trace has more than `usize::MAX` anchors.
trace_within(img, params, usize::MAX).unwrap_or_default()
@ -351,6 +370,9 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
if w == 0 || h == 0 || img.rgba.len() != w * h * 4 {
return Ok(TraceResult::default());
}
if params.mode == Mode::Logo {
return logo::trace_logo(img, params, max_anchors);
}
let mut q = quantize(img, params);
denoise(&mut q.labels, w, h, params.noise as usize);
// Pixel count per palette entry.

View File

@ -0,0 +1,196 @@
//! Least-squares cubic Bézier fitting (Schneider's algorithm): fit one cubic to a run of points,
//! improve its parametrisation, and split where the error is largest until every piece is within
//! the tolerance. After P. J. Schneider, "An algorithm for automatically fitting digitized curves"
//! (Graphics Gems, 1990), written from the description there.
use super::geom::{Cubic, bez};
use kurbo::Vec2;
use vectorcraft_geom::Point;
const MAX_DEPTH: usize = 14;
fn chord(p: &[Point]) -> Vec<f64> {
let mut d = vec![0.0];
for w in p.windows(2) {
d.push(d.last().copied().unwrap_or(0.0) + w[0].distance(w[1]));
}
let total = d.last().copied().unwrap_or(0.0);
if total > 0.0 {
d.iter_mut().for_each(|v| *v /= total);
}
d
}
fn fit_one(p: &[Point], u: &[f64], t1: Vec2, t2: Vec2) -> Option<Cubic> {
let (p0, p3) = (*p.first()?, *p.last()?);
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
for (q, &t) in p.iter().zip(u) {
let s = 1.0 - t;
let (b0, b1, b2, b3) = (s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t);
let (a1, a2) = (t1 * b1, t2 * b2);
c00 += a1.dot(a1);
c01 += a1.dot(a2);
c11 += a2.dot(a2);
let tmp = *q - (p0.to_vec2() * (b0 + b1) + p3.to_vec2() * (b2 + b3)).to_point();
x0 += a1.dot(tmp);
x1 += a2.dot(tmp);
}
let det = c00 * c11 - c01 * c01;
let seg = p0.distance(p3);
let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (0.0, 0.0) };
if !al.is_finite() || !ar.is_finite() || al <= 1e-6 * seg || ar <= 1e-6 * seg {
al = seg / 3.0;
ar = seg / 3.0;
}
Some([p0, p0 + t1 * al, p3 + t2 * ar, p3])
}
fn reparam(c: &Cubic, p: &[Point], u: &[f64]) -> Vec<f64> {
p.iter()
.zip(u)
.map(|(q, &t)| {
let s = 1.0 - t;
let d = bez(c, t) - *q;
let (v0, v1, v2) = (c[1] - c[0], c[2] - c[1], c[3] - c[2]);
let d1 = (v0 * (s * s) + v1 * (2.0 * s * t) + v2 * (t * t)) * 3.0;
let d2 = ((v1 - v0) * s + (v2 - v1) * t) * 6.0;
let den = d1.dot(d1) + d.dot(d2);
if den.abs() > 1e-12 { (t - d.dot(d1) / den).clamp(0.0, 1.0) } else { t }
})
.collect()
}
fn max_err(c: &Cubic, p: &[Point], u: &[f64]) -> (f64, usize) {
let mut best = (0.0, p.len() / 2);
for (i, (q, &t)) in p.iter().zip(u).enumerate() {
let e = bez(c, t).distance(*q);
if e > best.0 {
best = (e, i);
}
}
best
}
fn rec(p: &[Point], t1: Vec2, t2: Vec2, tol: f64, depth: usize, out: &mut Vec<Cubic>) {
let (Some(&a), Some(&b)) = (p.first(), p.last()) else { return };
if p.len() == 2 {
let d = a.distance(b) / 3.0;
out.push([a, a + t1 * d, b + t2 * d, b]);
return;
}
let mut u = chord(p);
let Some(mut c) = fit_one(p, &u, t1, t2) else { return };
for _ in 0..4 {
let (e, _) = max_err(&c, p, &u);
if e < tol {
out.push(c);
return;
}
if e > tol * 6.0 {
break;
}
u = reparam(&c, p, &u);
match fit_one(p, &u, t1, t2) {
Some(n) => c = n,
None => break,
}
}
let (e, split) = max_err(&c, p, &u);
if e < tol || depth >= MAX_DEPTH || p.len() < 6 {
out.push(c);
return;
}
let s = split.clamp(2, p.len() - 3);
let tc = match (p.get(s - 1), p.get(s + 1)) {
(Some(&x), Some(&y)) if x.distance(y) > 1e-12 => (x - y) / x.distance(y),
_ => t1,
};
rec(p.get(..=s).unwrap_or(p), t1, tc, tol, depth + 1, out);
rec(p.get(s..).unwrap_or(p), -tc, t2, tol, depth + 1, out);
}
/// Cubics through `p` (first and last points are interpolated) with end tangents `t1` (pointing
/// along the run at its start) and `t2` (pointing back along the run at its end), each within
/// `tol` of the points.
pub(crate) fn fit_cubics(p: &[Point], t1: Vec2, t2: Vec2, tol: f64) -> Vec<Cubic> {
let mut out = Vec::new();
if p.len() >= 2 && tol > 0.0 && p.iter().all(|q| q.x.is_finite() && q.y.is_finite()) {
rec(p, t1, t2, tol, 0, &mut out);
}
out
}
/// Unit direction from `p[i]` towards `p[i + k]` (`sign > 0`) or `p[i - k]` (`sign < 0`).
pub(crate) fn tangent(p: &[Point], i: usize, k: usize, sign: i32) -> Vec2 {
let j = if sign > 0 { (i + k).min(p.len().saturating_sub(1)) } else { i.saturating_sub(k) };
match (p.get(i), p.get(j)) {
(Some(&a), Some(&b)) if a.distance(b) > 1e-12 => (b - a) / a.distance(b),
_ => Vec2::new(1.0, 0.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logo::geom::sample_cubics;
fn dev(cs: &[Cubic], pts: &[Point]) -> f64 {
let s = sample_cubics(cs, 0.01);
pts.iter().map(|p| s.iter().map(|q| q.distance(*p)).fold(f64::MAX, f64::min)).fold(0.0, f64::max)
}
#[test]
fn a_circle_arc_is_one_cubic() {
let pts: Vec<Point> = (0..200).map(|i| Point::new(20.0 * (i as f64 * 0.006).cos(), 20.0 * (i as f64 * 0.006).sin())).collect();
let cs = fit_cubics(&pts, tangent(&pts, 0, 4, 1), tangent(&pts, 199, 4, -1), 0.05);
assert_eq!(cs.len(), 1);
assert!(dev(&cs, &pts) < 0.05);
}
#[test]
fn a_full_circle_stays_within_tolerance_with_few_pieces() {
let n = 1200;
let mut pts: Vec<Point> = (0..=n)
.map(|i| {
Point::new(
50.0 + 20.0 * (i as f64 / n as f64 * std::f64::consts::TAU).cos(),
50.0 + 20.0 * (i as f64 / n as f64 * std::f64::consts::TAU).sin(),
)
})
.collect();
pts[n] = pts[0];
let t = (pts[1] - pts[n - 1]) / pts[1].distance(pts[n - 1]);
let cs = fit_cubics(&pts, t, -t, 0.05);
assert!(cs.len() <= 12, "{} pieces", cs.len());
assert!(dev(&cs, &pts) < 0.06, "deviation {}", dev(&cs, &pts));
}
#[test]
fn a_wavy_curve_is_fitted_within_tolerance() {
let pts: Vec<Point> = (0..600).map(|i| Point::new(i as f64 * 0.05, 3.0 * (i as f64 * 0.02).sin())).collect();
let cs = fit_cubics(&pts, tangent(&pts, 0, 4, 1), tangent(&pts, 599, 4, -1), 0.04);
assert!(dev(&cs, &pts) < 0.05);
assert!(cs.len() < 20);
}
#[test]
fn pieces_join_end_to_end() {
let pts: Vec<Point> = (0..400).map(|i| Point::new(i as f64 * 0.1, (i as f64 * 0.05).sin() * 4.0)).collect();
let cs = fit_cubics(&pts, tangent(&pts, 0, 4, 1), tangent(&pts, 399, 4, -1), 0.02);
for w in cs.windows(2) {
assert!(w[0][3].distance(w[1][0]) < 1e-9);
}
}
#[test]
fn degenerate_input_gives_nothing_or_a_line_not_a_panic() {
let t = Vec2::new(1.0, 0.0);
assert!(fit_cubics(&[], t, t, 0.1).is_empty());
assert!(fit_cubics(&[Point::new(0.0, 0.0)], t, t, 0.1).is_empty());
assert_eq!(fit_cubics(&[Point::new(0.0, 0.0), Point::new(3.0, 0.0)], t, -t, 0.1).len(), 1);
assert!(fit_cubics(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0), Point::new(2.0, 0.0)], t, t, 0.1).is_empty());
assert!(fit_cubics(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0)], t, t, 0.0).is_empty());
// Duplicate points do not divide by zero.
let _ = fit_cubics(&[Point::new(1.0, 1.0); 10], t, t, 0.1);
}
}

View File

@ -0,0 +1,228 @@
//! Smooth sub-pixel fields: cubic B-spline interpolation of a coverage plane.
//!
//! Method: cubic B-spline interpolation with a recursive prefilter, as described in M. Unser, A.
//! Aldroubi and M. Eden, "B-spline signal processing" (IEEE Trans. Signal Processing, 1993) and P.
//! Thévenaz, T. Blu and M. Unser, "Interpolation revisited" (IEEE Trans. Medical Imaging, 2000).
//! Written from those papers.
//!
//! Interpolating coverage (an area fraction), not colour, and then comparing classes, puts an
//! edge where two classes are equally present, which is accurate to a fraction of a pixel.
/// Gaussian blur of a `w × h` plane (σ in pixels, edges repeated). Linear, so per-pixel weights that
/// sum to one across planes still do.
pub(crate) fn blur_plane(plane: &mut [f32], w: usize, h: usize, sigma: f64) {
if w == 0 || h == 0 || plane.len() < w * h || sigma.is_nan() || sigma <= 0.0 {
return;
}
let r = (3.0 * sigma).ceil() as i64;
let k: Vec<f64> = (-r..=r).map(|i| (-0.5 * (i as f64 / sigma).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
let at = |src: &[f32], x: i64, y: i64| {
f64::from(src.get(y.clamp(0, h as i64 - 1) as usize * w + x.clamp(0, w as i64 - 1) as usize).copied().unwrap_or(0.0))
};
let mut tmp = vec![0.0f32; w * h];
for y in 0..h as i64 {
for x in 0..w as i64 {
let v: f64 = k.iter().enumerate().map(|(j, kv)| kv * at(plane, x + j as i64 - r, y)).sum();
if let Some(d) = tmp.get_mut(y as usize * w + x as usize) {
*d = (v / sum) as f32;
}
}
}
for y in 0..h as i64 {
for x in 0..w as i64 {
let v: f64 = k.iter().enumerate().map(|(j, kv)| kv * at(&tmp, x, y + j as i64 - r)).sum();
if let Some(d) = plane.get_mut(y as usize * w + x as usize) {
*d = (v / sum) as f32;
}
}
}
}
/// Replace samples by cubic B-spline coefficients so that interpolation passes through them
/// (Unser's recursive filter, mirror boundaries).
fn prefilter_line(c: &mut [f64]) {
let n = c.len();
if n < 2 {
return;
}
let z = 3.0f64.sqrt() - 2.0;
let gain = (1.0 - z) * (1.0 - 1.0 / z);
for v in c.iter_mut() {
*v *= gain;
}
// Causal initial value: the exact mirror-boundary sum for short lines, a truncated one for long.
let at = |k: usize| c.get(k).copied().unwrap_or(0.0);
let horizon = ((1e-9f64).ln() / z.abs().ln()).ceil() as usize;
let first = if horizon < n {
let mut zk = z;
let mut sum = at(0);
for k in 1..horizon {
sum += zk * at(k);
zk *= z;
}
sum
} else {
let iz = 1.0 / z;
let mut zk = z;
let mut z2n = z.powi(n as i32 - 1);
let mut sum = at(0) + z2n * at(n - 1);
z2n = z2n * z2n * iz;
for k in 1..n - 1 {
sum += (zk + z2n) * at(k);
zk *= z;
z2n *= iz;
}
sum / (1.0 - zk * zk)
};
if let Some(v) = c.first_mut() {
*v = first;
}
for k in 1..n {
let prev = c.get(k - 1).copied().unwrap_or(0.0);
if let Some(v) = c.get_mut(k) {
*v += z * prev;
}
}
let (a, b) = (c.get(n - 2).copied().unwrap_or(0.0), c.get(n - 1).copied().unwrap_or(0.0));
if let Some(last) = c.last_mut() {
*last = (z / (z * z - 1.0)) * (z * a + b);
}
for k in (0..n - 1).rev() {
let next = c.get(k + 1).copied().unwrap_or(0.0);
if let Some(v) = c.get_mut(k) {
*v = z * (next - *v);
}
}
}
/// B-spline coefficients of a `w × h` plane.
fn coefficients(plane: &[f32], w: usize, h: usize) -> Vec<f64> {
let mut c: Vec<f64> = plane.iter().map(|&v| f64::from(v)).collect();
for row in c.chunks_mut(w.max(1)) {
prefilter_line(row);
}
let mut col = vec![0.0f64; h];
for x in 0..w {
for (y, v) in col.iter_mut().enumerate() {
*v = c.get(y * w + x).copied().unwrap_or(0.0);
}
prefilter_line(&mut col);
for (y, v) in col.iter().enumerate() {
if let Some(dst) = c.get_mut(y * w + x) {
*dst = *v;
}
}
}
c
}
/// First tap and the four cubic B-spline weights for every output sample along one axis.
fn taps(n_in: usize, u: usize) -> Vec<(i64, [f64; 4])> {
(0..n_in * u)
.map(|i| {
let x = (i as f64 + 0.5) / u as f64 - 0.5;
let i0 = x.floor();
let t = x - i0;
let t2 = t * t;
let t3 = t2 * t;
let w = [(1.0 - t).powi(3) / 6.0, (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0, (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0, t3 / 6.0];
(i0 as i64 - 1, w)
})
.collect()
}
/// The plane smoothly upsampled `u` times (sample centres stay aligned), clamped to `0..=1`.
pub(crate) fn upsample(plane: &[f32], w: usize, h: usize, u: usize) -> Vec<f32> {
if w == 0 || h == 0 || u == 0 || plane.len() < w * h {
return Vec::new();
}
let coef = coefficients(plane, w, h);
let (tx, ty) = (taps(w, u), taps(h, u));
let ow = w * u;
// Whole-sample mirror boundaries, matching the prefilter.
let clampi = |v: i64, n: usize| {
if n < 2 {
return 0;
}
let period = 2 * (n as i64 - 1);
let m = v.rem_euclid(period);
(if m < n as i64 { m } else { period - m }) as usize
};
let mut tmp = vec![0.0f64; h * ow];
for y in 0..h {
let row = coef.get(y * w..(y + 1) * w).unwrap_or(&[]);
for (ox, &(base, wt)) in tx.iter().enumerate() {
let mut s = 0.0;
for (j, wj) in wt.iter().enumerate() {
s += wj * row.get(clampi(base + j as i64, w)).copied().unwrap_or(0.0);
}
if let Some(d) = tmp.get_mut(y * ow + ox) {
*d = s;
}
}
}
let mut out = vec![0.0f32; ow * h * u];
for (oy, &(base, wt)) in ty.iter().enumerate() {
let rows: [usize; 4] = std::array::from_fn(|j| clampi(base + j as i64, h));
for ox in 0..ow {
let mut s = 0.0;
for (j, wj) in wt.iter().enumerate() {
s += wj * tmp.get(rows[j] * ow + ox).copied().unwrap_or(0.0);
}
if let Some(d) = out.get_mut(oy * ow + ox) {
*d = s.clamp(0.0, 1.0) as f32;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_constant_plane_stays_constant() {
let up = upsample(&[0.5; 6 * 5], 6, 5, 4);
assert_eq!(up.len(), 24 * 20);
assert!(up.iter().all(|&v| (v - 0.5).abs() < 1e-4));
}
#[test]
fn interpolation_passes_through_the_samples() {
// Sample (x, y) lives at the centre of its u×u block, i.e. between output samples; with
// u = 1 the output is the input.
let plane: Vec<f32> = (0..30).map(|i| ((i * 7) % 11) as f32 / 11.0).collect();
let up = upsample(&plane, 6, 5, 1);
for (a, b) in plane.iter().zip(&up) {
assert!((a - b).abs() < 1e-4, "{a} vs {b}");
}
}
#[test]
fn a_blurred_step_crosses_half_where_the_edge_is() {
// A vertical edge half-way through column 4: columns 0..4 are 0, column 4 is 0.5, then 1.
let (w, h) = (9, 3);
let plane: Vec<f32> = (0..w * h)
.map(|i| match i % w {
0..=3 => 0.0,
4 => 0.5,
_ => 1.0,
})
.collect();
let u = 8;
let up = upsample(&plane, w, h, u);
let row = &up[(h * u / 2) * (w * u)..(h * u / 2 + 1) * (w * u)];
let x = row.iter().position(|&v| v >= 0.5).unwrap_or(0);
// The 0.5 crossing sits at the centre of column 4 → output sample 4.5 * u = 36.
assert!((x as f64 - 36.0).abs() <= 1.5, "crossing at {x}");
}
#[test]
fn degenerate_input_is_empty_not_a_panic() {
assert!(upsample(&[], 0, 0, 4).is_empty());
assert!(upsample(&[1.0], 1, 1, 0).is_empty());
assert!(upsample(&[1.0], 2, 2, 3).is_empty());
}
}

View File

@ -0,0 +1,394 @@
//! Small geometry helpers for outlines: resampling, smoothing, corners, line and circle fits.
use kurbo::Vec2;
use std::collections::HashMap;
use vectorcraft_geom::Point;
/// One cubic Bézier segment: start, two handles, end.
pub(crate) type Cubic = [Point; 4];
/// Longest outline (in points) any step will build, whatever the input.
pub(crate) const MAX_POINTS: usize = 400_000;
pub(crate) fn finite(p: Point) -> bool {
p.x.is_finite() && p.y.is_finite()
}
pub(crate) fn bez(c: &Cubic, t: f64) -> Point {
let u = 1.0 - t;
let (a, b, cc, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Point::new(a * c[0].x + b * c[1].x + cc * c[2].x + d * c[3].x, a * c[0].y + b * c[1].y + cc * c[2].y + d * c[3].y)
}
/// A closed polyline resampled at (about) `step` spacing; also returns the exact spacing used.
pub(crate) fn resample_closed(p: &[Point], step: f64) -> Option<(Vec<Point>, f64)> {
let n = p.len();
if n < 3 || step.is_nan() || step <= 0.0 || !p.iter().all(|&q| finite(q)) {
return None;
}
let mut cum = Vec::with_capacity(n + 1);
cum.push(0.0);
for i in 0..n {
let (a, b) = (*p.get(i)?, *p.get((i + 1) % n)?);
cum.push(cum.last().copied().unwrap_or(0.0) + a.distance(b));
}
let total = cum.last().copied().unwrap_or(0.0);
if total.is_nan() || total <= step {
return None;
}
let m = ((total / step) as usize).clamp(8, MAX_POINTS);
let mut out = Vec::with_capacity(m);
let mut j = 0usize;
for i in 0..m {
let s = total * i as f64 / m as f64;
while cum.get(j + 1).is_some_and(|&c| c < s) {
j += 1;
}
let (c0, c1) = (*cum.get(j)?, *cum.get(j + 1)?);
let t = if c1 > c0 { (s - c0) / (c1 - c0) } else { 0.0 };
let (a, b) = (*p.get(j % n)?, *p.get((j + 1) % n)?);
out.push(a + (b - a) * t);
}
Some((out, total / m as f64))
}
fn kernel(sigma: f64) -> Vec<f64> {
let s = sigma.max(0.3);
let r = ((3.0 * s).ceil() as i64).max(1);
let k: Vec<f64> = (-r..=r).map(|i| (-0.5 * (i as f64 / s).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
k.iter().map(|v| v / sum).collect()
}
/// Gaussian smoothing of a closed polyline (σ in samples).
pub(crate) fn smooth_closed(p: &[Point], sigma: f64) -> Vec<Point> {
let n = p.len() as i64;
if n < 3 {
return p.to_vec();
}
let k = kernel(sigma);
let r = (k.len() as i64 - 1) / 2;
(0..n)
.map(|i| {
let mut s = Vec2::ZERO;
for (j, w) in k.iter().enumerate() {
let idx = (i + j as i64 - r).rem_euclid(n) as usize;
if let Some(q) = p.get(idx) {
s += q.to_vec2() * *w;
}
}
s.to_point()
})
.collect()
}
/// Gaussian smoothing of an open run with both ends pinned (the run is extended by odd reflection
/// so tangents at the ends survive).
pub(crate) fn smooth_open(p: &[Point], sigma: f64) -> Vec<Point> {
let n = p.len();
if n < 5 {
return p.to_vec();
}
let s = sigma.max(0.4).min(n as f64 / 8.0).max(0.4);
let k = kernel(s);
let r = (k.len() - 1) / 2;
let r_ext = r.min(n - 2);
let (first, last) = match (p.first(), p.last()) {
(Some(&f), Some(&l)) => (f, l),
_ => return p.to_vec(),
};
let mut ext: Vec<Point> = Vec::with_capacity(n + 2 * r_ext);
for i in (1..=r_ext).rev() {
ext.push(first + (first - p.get(i).copied().unwrap_or(first)));
}
ext.extend_from_slice(p);
for i in 1..=r_ext {
ext.push(last + (last - p.get(n - 1 - i).copied().unwrap_or(last)));
}
let kk = if r_ext == r { k } else { kernel_of_radius(s, r_ext) };
let rr = (kk.len() - 1) / 2;
let mut out: Vec<Point> = (0..n)
.map(|i| {
let mut acc = Vec2::ZERO;
for (j, w) in kk.iter().enumerate() {
let idx = i + r_ext + j;
if let Some(q) = idx.checked_sub(rr).and_then(|x| ext.get(x)) {
acc += q.to_vec2() * *w;
}
}
acc.to_point()
})
.collect();
if let (Some(f), Some(l)) = (out.first_mut(), p.first()) {
*f = *l;
}
if let (Some(f), Some(l)) = (out.last_mut(), p.last()) {
*f = *l;
}
out
}
fn kernel_of_radius(sigma: f64, r: usize) -> Vec<f64> {
let k: Vec<f64> = (-(r as i64)..=r as i64).map(|i| (-0.5 * (i as f64 / sigma).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
k.iter().map(|v| v / sum).collect()
}
/// Turning angle (degrees) at each point of a closed polyline over a window of `w` samples.
pub(crate) fn turning(p: &[Point], w: usize) -> Vec<f64> {
let n = p.len();
(0..n)
.map(|i| {
let (Some(&c), Some(&a), Some(&b)) = (p.get(i), p.get((i + n - w % n) % n), p.get((i + w) % n)) else { return 0.0 };
let (v1, v2) = (c - a, b - c);
(v1.cross(v2)).atan2(v1.dot(v2)).abs().to_degrees()
})
.collect()
}
/// Local maxima of the turning angle above `thr` degrees, at least `w` samples apart.
pub(crate) fn find_corners(p: &[Point], w: usize, thr: f64) -> Vec<usize> {
let n = p.len();
if n < 8 || w == 0 {
return Vec::new();
}
let ang = turning(p, w);
let at = |i: usize| ang.get(i % n).copied().unwrap_or(0.0);
let mut idx: Vec<usize> = Vec::new();
for i in 0..n {
let a = at(i);
if a >= thr && a >= at(i + n - 1) && a > at(i + 1) && idx.last().is_none_or(|&l| i - l > w) {
idx.push(i);
}
}
if idx.len() > 1
&& let (Some(&f), Some(&l)) = (idx.first(), idx.last())
&& f + n - l <= w
{
idx.pop();
}
idx
}
/// A straight line through `pts`: centroid, unit direction (first → last) and the largest
/// distance of any point from the line.
pub(crate) struct Line {
pub c: Point,
pub d: Vec2,
pub resid: f64,
}
pub(crate) fn line_fit(pts: &[Point]) -> Option<Line> {
let n = pts.len();
if n < 2 {
return None;
}
let c = (pts.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / n as f64).to_point();
let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
for p in pts {
let v = *p - c;
sxx += v.x * v.x;
sxy += v.x * v.y;
syy += v.y * v.y;
}
let ang = 0.5 * (2.0 * sxy).atan2(sxx - syy);
let mut d = Vec2::new(ang.cos(), ang.sin());
if d.dot(*pts.last()? - *pts.first()?) < 0.0 {
d = -d;
}
let nrm = Vec2::new(-d.y, d.x);
let resid = pts.iter().map(|p| (*p - c).dot(nrm).abs()).fold(0.0, f64::max);
(resid.is_finite()).then_some(Line { c, d, resid })
}
fn solve3(m: [f64; 9], b: [f64; 3]) -> Option<[f64; 3]> {
let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]);
if det.abs() < 1e-12 {
return None;
}
let rep = |col: usize| {
let mut t = m;
for r in 0..3 {
t[r * 3 + col] = b[r];
}
t[0] * (t[4] * t[8] - t[5] * t[7]) - t[1] * (t[3] * t[8] - t[5] * t[6]) + t[2] * (t[3] * t[7] - t[4] * t[6])
};
Some([rep(0) / det, rep(1) / det, rep(2) / det])
}
/// A circle through `pts` (algebraic fit): centre, radius, largest radial error.
pub(crate) struct Circle {
pub c: Point,
pub r: f64,
pub resid: f64,
}
pub(crate) fn circle_fit(pts: &[Point]) -> Option<Circle> {
if pts.len() < 3 {
return None;
}
// Centre the data for conditioning.
let m = (pts.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / pts.len() as f64).to_point();
let (mut a, mut b) = ([0.0f64; 9], [0.0f64; 3]);
for p in pts {
let (x, y) = (p.x - m.x, p.y - m.y);
let row = [2.0 * x, 2.0 * y, 1.0];
let rhs = x * x + y * y;
for i in 0..3 {
for j in 0..3 {
a[i * 3 + j] += row[i] * row[j];
}
b[i] += row[i] * rhs;
}
}
let s = solve3(a, b)?;
let r2 = s[2] + s[0] * s[0] + s[1] * s[1];
if r2.is_nan() || r2 <= 1e-12 {
return None;
}
let (c, r) = (Point::new(s[0] + m.x, s[1] + m.y), r2.sqrt());
let resid = pts.iter().map(|p| (p.distance(c) - r).abs()).fold(0.0, f64::max);
(resid.is_finite() && r.is_finite()).then_some(Circle { c, r, resid })
}
/// Cubic Béziers approximating the arc of radius `r` about `c` from angle `a0` to `a1`.
pub(crate) fn arc_cubics(c: Point, r: f64, a0: f64, a1: f64) -> Vec<Cubic> {
let n = ((a1 - a0).abs() / std::f64::consts::FRAC_PI_2).ceil().clamp(1.0, 64.0) as usize;
let da = (a1 - a0) / n as f64;
let k = 4.0 / 3.0 * (da / 4.0).tan();
(0..n)
.map(|j| {
let (s, e) = (a0 + j as f64 * da, a0 + (j + 1) as f64 * da);
let p0 = Point::new(c.x + r * s.cos(), c.y + r * s.sin());
let p3 = Point::new(c.x + r * e.cos(), c.y + r * e.sin());
[p0, p0 + Vec2::new(-s.sin(), s.cos()) * (k * r), p3 - Vec2::new(-e.sin(), e.cos()) * (k * r), p3]
})
.collect()
}
/// Points along the curves, about `spacing` apart.
pub(crate) fn sample_cubics(cs: &[Cubic], spacing: f64) -> Vec<Point> {
let mut out = Vec::new();
for c in cs {
let len = (c[1] - c[0]).hypot() + (c[2] - c[1]).hypot() + (c[3] - c[2]).hypot();
let n = ((len / spacing.max(1e-3)) as usize).clamp(8, 20_000);
out.extend((0..=n).map(|i| bez(c, i as f64 / n as f64)));
if out.len() > MAX_POINTS * 4 {
break;
}
}
out
}
/// True when every point of `a` has a point of `b` within `lim`.
pub(crate) fn all_within(a: &[Point], b: &[Point], lim: f64) -> bool {
if lim.is_nan() || lim <= 0.0 {
return false;
}
let key = |p: Point| ((p.x / lim).floor() as i64, (p.y / lim).floor() as i64);
let mut grid: HashMap<(i64, i64), Vec<Point>> = HashMap::new();
for &q in b {
grid.entry(key(q)).or_default().push(q);
}
a.iter().all(|&p| {
let (kx, ky) = key(p);
(-1..=1).any(|dx| (-1..=1).any(|dy| grid.get(&(kx + dx, ky + dy)).is_some_and(|v| v.iter().any(|q| q.distance(p) <= lim))))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn circle_pts(n: usize, r: f64) -> Vec<Point> {
(0..n)
.map(|i| {
Point::new(
50.0 + r * (i as f64 / n as f64 * std::f64::consts::TAU).cos(),
50.0 + r * (i as f64 / n as f64 * std::f64::consts::TAU).sin(),
)
})
.collect()
}
#[test]
fn resampling_makes_even_spacing() {
let (r, step) = resample_closed(&circle_pts(37, 20.0), 0.5).expect("resample");
assert!((step - 0.5).abs() < 0.01);
let gaps: Vec<f64> = (0..r.len()).map(|i| r[i].distance(r[(i + 1) % r.len()])).collect();
assert!(gaps.iter().all(|g| (g - step).abs() < 0.02), "{gaps:?}");
}
#[test]
fn resampling_refuses_degenerate_input() {
assert!(resample_closed(&[], 1.0).is_none());
assert!(resample_closed(&[Point::new(0.0, 0.0); 5], 1.0).is_none());
assert!(resample_closed(&circle_pts(10, 5.0), 0.0).is_none());
assert!(resample_closed(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0)], 0.1).is_none());
}
#[test]
fn smoothing_a_circle_keeps_its_radius() {
let s = smooth_closed(&circle_pts(600, 20.0), 4.0);
for p in &s {
assert!((p.distance(Point::new(50.0, 50.0)) - 20.0).abs() < 0.05);
}
}
#[test]
fn open_smoothing_pins_the_ends() {
let run: Vec<Point> = (0..60).map(|i| Point::new(i as f64 * 0.2, (i as f64 * 0.7).sin() * 0.05)).collect();
let s = smooth_open(&run, 3.0);
assert_eq!((s[0], s[59]), (run[0], run[59]));
assert_eq!(s.len(), run.len());
}
#[test]
fn a_square_has_four_corners() {
let sq: Vec<Point> = (0..400)
.map(|i| {
let t = i as f64 / 100.0;
match t as usize {
0 => Point::new(t * 10.0, 0.0),
1 => Point::new(10.0, (t - 1.0) * 10.0),
2 => Point::new(10.0 - (t - 2.0) * 10.0, 10.0),
_ => Point::new(0.0, 10.0 - (t - 3.0) * 10.0),
}
})
.collect();
assert_eq!(find_corners(&sq, 6, 60.0).len(), 4);
}
#[test]
fn a_circle_has_no_corners() {
assert!(find_corners(&circle_pts(500, 20.0), 6, 40.0).is_empty());
}
#[test]
fn line_and_circle_fits_recover_their_shapes() {
let line: Vec<Point> = (0..50).map(|i| Point::new(i as f64, 2.0 * i as f64 + 1.0)).collect();
let l = line_fit(&line).expect("line");
assert!(l.resid < 1e-9 && l.d.x > 0.0);
let c = circle_fit(&circle_pts(100, 12.5)).expect("circle");
assert!((c.r - 12.5).abs() < 1e-6 && c.c.distance(Point::new(50.0, 50.0)) < 1e-6 && c.resid < 1e-6);
assert!(circle_fit(&line).is_none() || circle_fit(&line).is_some_and(|c| c.r > 1e3 || c.resid > 1.0));
assert!(line_fit(&[Point::new(0.0, 0.0)]).is_none());
}
#[test]
fn arcs_stay_on_the_circle() {
let cs = arc_cubics(Point::new(5.0, 5.0), 10.0, 0.3, 4.0);
for p in sample_cubics(&cs, 0.05) {
assert!((p.distance(Point::new(5.0, 5.0)) - 10.0).abs() < 0.01);
}
}
#[test]
fn within_checks_both_near_and_far() {
let a = circle_pts(100, 10.0);
let b = circle_pts(100, 10.2);
assert!(all_within(&a, &b, 0.5));
assert!(!all_within(&a, &b, 0.1));
assert!(!all_within(&a, &b, 0.0));
}
}

View File

@ -0,0 +1,175 @@
//! Marching squares: closed contours of a scalar field at level 0.
//!
//! Segments keep the positive side on their left (x right, y down), so a region's outer boundary
//! and its holes wind in opposite directions and need no point-in-polygon test to tell apart.
use std::collections::HashMap;
use vectorcraft_geom::Point;
/// Edge ids: `(y * w + x) * 2` is the horizontal edge from `(x, y)` to `(x + 1, y)`, `+ 1` the
/// vertical edge from `(x, y)` to `(x, y + 1)`.
fn edge_point(f: &[f32], w: usize, id: usize) -> Point {
let cell = id / 2;
let (x, y) = (cell % w, cell / w);
let at = |xx: usize, yy: usize| f64::from(f.get(yy * w + xx).copied().unwrap_or(0.0));
if id.is_multiple_of(2) {
let (a, b) = (at(x, y), at(x + 1, y));
let t = if (a - b).abs() < 1e-12 { 0.5 } else { (a / (a - b)).clamp(0.0, 1.0) };
Point::new(x as f64 + t, y as f64)
} else {
let (a, b) = (at(x, y), at(x, y + 1));
let t = if (a - b).abs() < 1e-12 { 0.5 } else { (a / (a - b)).clamp(0.0, 1.0) };
Point::new(x as f64, y as f64 + t)
}
}
/// Closed loops (grid coordinates, sample `(x, y)` at `(x, y)`) around the region where `f > 0`.
/// Loops reaching the grid border are not closed by this function: pad the field with negatives.
pub(crate) fn contours(f: &[f32], w: usize, h: usize) -> Vec<Vec<Point>> {
if w < 2 || h < 2 || f.len() < w * h {
return Vec::new();
}
let mut next: HashMap<usize, usize> = HashMap::new();
for y in 0..h - 1 {
for x in 0..w - 1 {
let v = |xx: usize, yy: usize| f.get(yy * w + xx).copied().unwrap_or(-1.0);
let (tl, tr, br, bl) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1));
let case = usize::from(tl > 0.0) | usize::from(tr > 0.0) << 1 | usize::from(br > 0.0) << 2 | usize::from(bl > 0.0) << 3;
if case == 0 || case == 15 {
continue;
}
let c = y * w + x;
let (t, r, b, l) = (c * 2, (c + 1) * 2 + 1, (c + w) * 2, c * 2 + 1);
let centre = (tl + tr + br + bl) * 0.25 > 0.0;
let segs: &[(usize, usize)] = match case {
1 => &[(l, t)],
2 => &[(t, r)],
3 => &[(l, r)],
4 => &[(r, b)],
5 => {
if centre {
&[(r, t), (l, b)]
} else {
&[(l, t), (r, b)]
}
}
6 => &[(t, b)],
7 => &[(l, b)],
8 => &[(b, l)],
9 => &[(b, t)],
10 => {
if centre {
&[(t, l), (b, r)]
} else {
&[(t, r), (b, l)]
}
}
11 => &[(b, r)],
12 => &[(r, l)],
13 => &[(r, t)],
14 => &[(t, l)],
_ => &[],
};
for &(from, to) in segs {
next.insert(from, to);
}
}
}
let mut loops = Vec::new();
let mut starts: Vec<usize> = next.keys().copied().collect();
starts.sort_unstable();
for s in starts {
if !next.contains_key(&s) {
continue;
}
let mut pts = Vec::new();
let mut cur = s;
let mut closed = false;
for _ in 0..next.len() + 1 {
let Some(n) = next.remove(&cur) else { break };
pts.push(edge_point(f, w, cur));
cur = n;
if cur == s {
closed = true;
break;
}
}
if closed && pts.len() >= 3 {
loops.push(pts);
}
}
loops
}
/// Twice the signed area of a closed polygon (shoelace, x right, y down). With the positive side
/// on the left, the **outer** boundary of a region is negative and its **holes** are positive.
pub(crate) fn area2(p: &[Point]) -> f64 {
let n = p.len();
(0..n)
.map(|i| {
let (a, b) = (p.get(i).copied().unwrap_or_default(), p.get((i + 1) % n).copied().unwrap_or_default());
a.x * b.y - b.x * a.y
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
/// `f` = positive inside a disc of radius `r` at `(cx, cy)`, on a padded grid.
fn disc(w: usize, h: usize, cx: f64, cy: f64, r: f64) -> Vec<f32> {
(0..w * h).map(|i| (r - ((i % w) as f64 - cx).hypot((i / w) as f64 - cy)) as f32).collect()
}
#[test]
fn a_disc_gives_one_loop_of_the_right_size() {
let f = disc(40, 40, 20.0, 20.0, 9.0);
let loops = contours(&f, 40, 40);
assert_eq!(loops.len(), 1);
let a = area2(&loops[0]).abs() / 2.0;
assert!((a - std::f64::consts::PI * 81.0).abs() / (std::f64::consts::PI * 81.0) < 0.01, "area {a}");
// Every vertex is on the circle to well under a hundredth of a cell.
for p in &loops[0] {
assert!(((p.x - 20.0).hypot(p.y - 20.0) - 9.0).abs() < 0.05);
}
}
#[test]
fn outer_boundaries_and_holes_wind_in_opposite_directions() {
// A ring: positive between radius 5 and 10.
let f: Vec<f32> = (0..50 * 50)
.map(|i| {
let d = ((i % 50) as f64 - 25.0).hypot((i / 50) as f64 - 25.0);
(5.0 - (d - 7.5).abs()) as f32
})
.collect();
let loops = contours(&f, 50, 50);
assert_eq!(loops.len(), 2);
let mut a: Vec<f64> = loops.iter().map(|l| area2(l)).collect();
a.sort_by(f64::total_cmp);
assert!(a[0] < 0.0 && a[1] > 0.0, "outer and hole must differ in sign: {a:?}");
// The outer loop is the bigger one and has the negative area; the hole is positive.
assert!(a[0].abs() > a[1].abs());
}
#[test]
fn two_separate_blobs_give_two_loops() {
let mut f = disc(60, 30, 15.0, 15.0, 6.0);
let g = disc(60, 30, 45.0, 15.0, 6.0);
for (a, b) in f.iter_mut().zip(&g) {
*a = a.max(*b);
}
assert_eq!(contours(&f, 60, 30).len(), 2);
}
#[test]
fn saddles_and_degenerate_grids_do_not_panic() {
// A checkerboard is all saddles.
let f: Vec<f32> = (0..64).map(|i| if (i % 8 + i / 8) % 2 == 0 { 1.0 } else { -1.0 }).collect();
let _ = contours(&f, 8, 8);
assert!(contours(&[], 0, 0).is_empty());
assert!(contours(&[1.0], 1, 1).is_empty());
assert!(contours(&[1.0; 3], 4, 4).is_empty());
}
}

View File

@ -0,0 +1,27 @@
//! Logo mode: flat-colour logos traced to clean, sub-pixel-accurate vector shapes.
//!
//! The tracer in the other modes follows hard pixel edges, so an outline can only be as good as
//! the pixel grid. Here each pixel is read as a *blend* of the logo's flat colours, the blend is
//! interpolated smoothly, and every outline is placed where two colours are equally present, to a
//! fraction of a pixel. The outlines are then cleaned the way a person would: corners the blur
//! rounded are put back, straight runs and circular arcs become exact lines and arcs, and the
//! rest is fitted with as few Bézier curves as the tolerance allows. Areas of transparency
//! fully enclosed by artwork (letters knocked out of a badge, the counters of an "o") are painted
//! white, so the logo reads the same on any background; with Ignore White they stay real holes.
//! Flat-colour logos only: gradients and photos are refused ([`TraceError::NotFlat`](crate::TraceError)).
mod bezier;
mod field;
mod geom;
mod march;
mod outline;
mod palette;
mod pipeline;
mod restore;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod testutil;
mod unmix;
pub(crate) use pipeline::trace_logo;

View File

@ -0,0 +1,306 @@
//! One outline: smooth, restore corners, snap to lines and arcs, fit Béziers, and verify.
use super::bezier::{fit_cubics, tangent};
use super::geom::{Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, sample_cubics, smooth_closed, smooth_open};
use super::restore::{RestoreParams, restore_corners};
use vectorcraft_geom::Point;
/// How an outline is fitted. Lengths are in (working) pixels.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Style {
pub step: f64,
pub sigma: f64,
pub corner_deg: f64,
pub corner_win: f64,
pub tol: f64,
pub snap: bool,
pub line_tol: f64,
pub circle_tol: f64,
pub restore: Option<RestoreParams>,
/// An outline whose fit strays farther than this from the raw contour is fitted plainly.
pub max_dev: f64,
}
impl Default for Style {
fn default() -> Self {
Self {
step: 0.12,
sigma: 0.5,
corner_deg: 60.0,
corner_win: 0.7,
tol: 0.12,
snap: true,
line_tol: 0.22,
circle_tol: 0.25,
restore: Some(RestoreParams::default()),
max_dev: 1.4,
}
}
}
/// What fitting did, for tests and diagnostics.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Stats {
pub restored: usize,
pub lines: usize,
pub arcs: usize,
pub circles: usize,
pub reverted: usize,
}
fn line_cubic(a: Point, b: Point) -> Cubic {
let d = b - a;
[a, a + d / 3.0, a + d * (2.0 / 3.0), b]
}
/// The sweep of angles from the first to the last point of `pts` about `c` (continuous, may exceed π).
fn sweep(pts: &[Point], c: Point) -> Option<(f64, f64)> {
let ang = |p: &Point| (p.y - c.y).atan2(p.x - c.x);
let mut prev = ang(pts.first()?);
let a0 = prev;
let mut total = 0.0;
for p in pts.iter().skip(1) {
let a = ang(p);
let mut d = a - prev;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
total += d;
prev = a;
}
Some((a0, a0 + total))
}
/// A plain fit of the closed raw contour: no smoothing, corners or snapping.
fn plain_fit(p: &[Point], tol: f64) -> Vec<Cubic> {
let n = p.len();
let (Some(&first), Some(&second), Some(&last)) = (p.first(), p.get(1), p.get(n.saturating_sub(1))) else { return Vec::new() };
let t = second - last;
let len = t.hypot();
if len.is_nan() || len <= 1e-12 {
return Vec::new();
}
let t = t / len;
let mut closed = p.to_vec();
closed.push(first);
fit_cubics(&closed, t, -t, tol)
}
fn styled_fit(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>> {
let (mut r, step) = resample_closed(p, st.step)?;
let sg = st.sigma / step;
let light = (0.18 / step).max(1.0);
let mut s0 = smooth_closed(&r, light);
let mut forced: Vec<usize> = Vec::new();
if let Some(rp) = &st.restore {
let (nr, f) = restore_corners(&r, &s0, step, rp);
if !f.is_empty() {
stats.restored += f.len();
s0 = nr.clone();
r = nr;
forced = f;
}
}
let w = ((st.corner_win / step).round() as usize).max(2);
let mut corners = find_corners(&s0, w, st.corner_deg);
corners.extend(forced);
corners.sort_unstable();
corners.dedup();
let n = r.len();
if corners.is_empty() {
let s = smooth_closed(&r, sg);
if st.snap
&& let Some(c) = circle_fit(&s)
&& c.resid < st.circle_tol
&& c.r > 2.5
{
// Keep the loop's own direction and starting point: holes must stay opposite to
// their outlines or the winding rule would add them instead of cancelling.
if let Some((a0, a1)) = sweep(&s, c.c) {
let dir = if a1 >= a0 { 1.0 } else { -1.0 };
stats.circles += 1;
return Some(arc_cubics(c.c, c.r, a0, a0 + dir * std::f64::consts::TAU));
}
}
let (Some(&a), Some(&b), Some(&z)) = (s.first(), s.get(1), s.last()) else { return None };
let t = b - z;
let len = t.hypot();
if len.is_nan() || len <= 1e-12 {
return None;
}
let t = t / len;
let mut q = s;
q.push(a);
return Some(fit_cubics(&q, t, -t, st.tol));
}
let mut segs: Vec<Cubic> = Vec::new();
for (ci, &i0) in corners.iter().enumerate() {
let i1 = *corners.get((ci + 1) % corners.len())?;
let count = if i1 <= i0 { i1 + n - i0 } else { i1 - i0 } + 1;
let run: Vec<Point> = (0..count).filter_map(|k| r.get((i0 + k) % n).copied()).collect();
let (Some(&first), Some(&last)) = (run.first(), run.last()) else { continue };
if run.len() < 4 {
if run.len() >= 2 && first.distance(last) > 1e-9 {
segs.push(line_cubic(first, last));
}
continue;
}
let sm = smooth_open(&run, sg);
let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { continue };
let len = a.distance(b);
if st.snap && len > 1.5 {
let d = b - a;
let nrm = kurbo::Vec2::new(-d.y, d.x) / len;
if sm.iter().all(|q| (*q - a).dot(nrm).abs() < st.line_tol) {
stats.lines += 1;
segs.push(line_cubic(a, b));
continue;
}
}
if st.snap
&& sm.len() > 12
&& len > 1.0
&& let Some(c) = circle_fit(&sm)
&& c.resid < st.circle_tol
&& c.r > 2.5
&& c.r < 400.0
&& let Some((a0, a1)) = sweep(&sm, c.c)
&& (a1 - a0).abs() > 0.35
{
stats.arcs += 1;
let first = segs.len();
segs.extend(arc_cubics(c.c, c.r, a0, a1));
// The fitted circle may miss the corners by up to the snap tolerance: end exactly on them.
if let Some(s) = segs.get_mut(first) {
s[0] = a;
}
if let Some(s) = segs.last_mut() {
s[3] = b;
}
continue;
}
segs.extend(fit_cubics(&sm, tangent(&sm, 0, 4, 1), tangent(&sm, sm.len() - 1, 4, -1), st.tol));
}
(!segs.is_empty()).then_some(segs)
}
fn sane(segs: &[Cubic]) -> bool {
!segs.is_empty() && segs.iter().all(|c| c.iter().all(|&p| finite(p)))
}
/// The outline `p` (a closed loop) as cubic Béziers, or `None` if it is too small or degenerate.
pub(crate) fn fit_outline(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>> {
if p.len() < 3 || !p.iter().all(|&q| finite(q)) {
return None;
}
let mut local = Stats::default();
// The reference is the raw outline, densified so the check never depends on how sparse it is.
let reference = resample_closed(p, 0.1).map_or_else(|| p.to_vec(), |(d, _)| d);
let fitted = styled_fit(p, st, &mut local).filter(|s| sane(s)).filter(|s| {
let samples = sample_cubics(s, 0.03);
all_within(&samples, &reference, st.max_dev) && all_within(&reference, &samples, st.max_dev)
});
match fitted {
Some(segs) => {
stats.restored += local.restored;
stats.lines += local.lines;
stats.arcs += local.arcs;
stats.circles += local.circles;
Some(segs)
}
None => {
stats.reverted += 1;
Some(plain_fit(p, st.tol)).filter(|s| sane(s))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logo::testutil::{circle, rounded_square};
fn near_any(segs: &[Cubic], target: Point) -> f64 {
sample_cubics(segs, 0.01).iter().map(|q| q.distance(target)).fold(f64::MAX, f64::min)
}
#[test]
fn a_rounded_square_becomes_a_sharp_square_with_straight_sides() {
let mut st = Stats::default();
let segs = fit_outline(&rounded_square(0.7), &Style::default(), &mut st).expect("fit");
assert_eq!(st.restored, 4);
assert_eq!(st.lines, 4, "{st:?}");
assert_eq!(segs.len(), 4, "a square is four lines");
for (x, y) in [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)] {
assert!(near_any(&segs, Point::new(x, y)) < 0.1);
}
assert_eq!(st.reverted, 0);
}
#[test]
fn a_disc_becomes_a_true_circle() {
let mut st = Stats::default();
let segs = fit_outline(&circle(25.0, 25.0, 12.0, 700), &Style::default(), &mut st).expect("fit");
assert_eq!(st.circles, 1);
assert_eq!(segs.len(), 4);
// Smoothing shrinks a curve by about sigma^2 / (2 r): 0.01 px here.
for q in sample_cubics(&segs, 0.05) {
assert!((q.distance(Point::new(25.0, 25.0)) - 12.0).abs() < 0.03);
}
}
#[test]
fn a_snapped_circle_keeps_the_direction_of_its_loop() {
let forward = circle(25.0, 25.0, 12.0, 700);
let mut backward = forward.clone();
backward.reverse();
let signed = |segs: &[Cubic]| {
let p = sample_cubics(segs, 0.1);
let n = p.len();
(0..n).map(|i| p[i].x * p[(i + 1) % n].y - p[(i + 1) % n].x * p[i].y).sum::<f64>()
};
let (mut a, mut b) = (Stats::default(), Stats::default());
let f = fit_outline(&forward, &Style::default(), &mut a).expect("forward");
let r = fit_outline(&backward, &Style::default(), &mut b).expect("backward");
assert_eq!((a.circles, b.circles), (1, 1));
assert!(signed(&f) > 0.0 && signed(&r) < 0.0, "directions {} and {}", signed(&f), signed(&r));
}
#[test]
fn without_snapping_a_circle_is_still_smooth_and_close() {
let mut st = Stats::default();
let style = Style { snap: false, restore: None, ..Style::default() };
let segs = fit_outline(&circle(25.0, 25.0, 12.0, 700), &style, &mut st).expect("fit");
assert_eq!(st.circles, 0);
for q in sample_cubics(&segs, 0.05) {
assert!((q.distance(Point::new(25.0, 25.0)) - 12.0).abs() < 0.2);
}
}
#[test]
fn the_guard_puts_back_a_plain_fit_when_the_clever_one_strays() {
let mut st = Stats::default();
// An absurdly tight limit makes every smoothed fit "stray".
let style = Style { max_dev: 1e-6, ..Style::default() };
let raw = rounded_square(0.7);
let segs = fit_outline(&raw, &style, &mut st);
assert_eq!(st.reverted, 1);
assert!(segs.is_some_and(|s| !s.is_empty()));
}
#[test]
fn degenerate_outlines_give_none_not_a_panic() {
let mut st = Stats::default();
let s = Style::default();
assert!(fit_outline(&[], &s, &mut st).is_none());
assert!(fit_outline(&[Point::new(1.0, 1.0); 3], &s, &mut st).is_none() || st.reverted > 0);
assert!(fit_outline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0), Point::new(2.0, 0.0)], &s, &mut st).is_none());
let tiny = [Point::new(0.0, 0.0), Point::new(0.01, 0.0), Point::new(0.0, 0.01)];
let _ = fit_outline(&tiny, &s, &mut st);
let _ = fit_outline(&circle(5.0, 5.0, 1e-9, 20), &s, &mut st);
let _ = fit_outline(&circle(5.0, 5.0, 1e7, 50), &s, &mut st);
}
}

View File

@ -0,0 +1,287 @@
//! Finding a logo's flat colours, and telling flat-colour art from gradients and photos.
use super::unmix::Px;
/// A colour with every channel at or above this is white, which is always a class of its own.
pub(crate) const WHITE_MIN: u8 = 245;
/// `#rrggbb` or `rrggbb`.
pub(crate) fn parse_hex(s: &str) -> Option<[u8; 3]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
return None;
}
let v = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok();
Some([v(0)?, v(2)?, v(4)?])
}
fn rgb255(p: &Px) -> [f64; 3] {
let a = f64::from(p[3]).max(1e-6);
[f64::from(p[0]) / a * 255.0, f64::from(p[1]) / a * 255.0, f64::from(p[2]) / a * 255.0]
}
/// Opaque pixels whose 3×3 neighbourhood is uniform (variance below `max_var`): edge pixels, which
/// are blends, do not get a vote.
fn solid_pixels(pix: &[Px], w: usize, h: usize, max_var: f64) -> Vec<[f64; 3]> {
let mut out = Vec::new();
if w < 3 || h < 3 {
return pix.iter().filter(|p| p[3] > 0.98).map(rgb255).collect();
}
for y in 1..h - 1 {
for x in 1..w - 1 {
let Some(c) = pix.get(y * w + x) else { continue };
if c[3] <= 0.98 {
continue;
}
let (mut s, mut s2, mut cnt) = ([0.0f64; 3], [0.0f64; 3], 0.0f64);
let mut opaque = true;
for dy in 0..3 {
for dx in 0..3 {
let Some(q) = pix.get((y + dy - 1) * w + (x + dx - 1)) else { continue };
if q[3] <= 0.98 {
opaque = false;
}
let v = rgb255(q);
for k in 0..3 {
s[k] += v[k];
s2[k] += v[k] * v[k];
}
cnt += 1.0;
}
}
if !opaque || cnt < 9.0 {
continue;
}
let var: f64 = (0..3).map(|k| s2[k] / cnt - (s[k] / cnt).powi(2)).sum();
if var < max_var {
out.push(rgb255(c));
}
}
}
out
}
fn dist2(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|k| (a[k] - b[k]).powi(2)).sum()
}
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((self.0 >> 33) as f64) / ((1u64 << 31) as f64)
}
}
fn kmeans(px: &[[f64; 3]], k: usize, seed: u64) -> (Vec<[f64; 3]>, Vec<usize>, f64) {
let mut rng = Lcg(seed);
let mut c: Vec<[f64; 3]> = vec![px[(rng.next() * px.len() as f64) as usize % px.len()]];
while c.len() < k {
let d: Vec<f64> = px.iter().map(|p| c.iter().map(|q| dist2(*p, *q)).fold(f64::MAX, f64::min)).collect();
let total: f64 = d.iter().sum();
if total <= 0.0 {
break;
}
let mut r = rng.next() * total;
let mut pick = px.len() - 1;
for (i, v) in d.iter().enumerate() {
r -= v;
if r <= 0.0 {
pick = i;
break;
}
}
c.push(px[pick]);
}
let mut lab = vec![0usize; px.len()];
for _ in 0..30 {
for (i, p) in px.iter().enumerate() {
lab[i] = (0..c.len()).min_by(|&a, &b| dist2(*p, c[a]).total_cmp(&dist2(*p, c[b]))).unwrap_or(0);
}
let mut sum = vec![[0.0f64; 3]; c.len()];
let mut cnt = vec![0usize; c.len()];
for (p, &l) in px.iter().zip(&lab) {
for j in 0..3 {
sum[l][j] += p[j];
}
cnt[l] += 1;
}
for (i, ci) in c.iter_mut().enumerate() {
if cnt[i] > 0 {
*ci = [sum[i][0] / cnt[i] as f64, sum[i][1] / cnt[i] as f64, sum[i][2] / cnt[i] as f64];
}
}
}
let cost = px.iter().zip(&lab).map(|(p, &l)| dist2(*p, c[l])).sum();
(c, lab, cost)
}
/// Up to `max_colors` flat colours, most common first. White is left out (it is always a class of
/// its own), as are colours that make up under 0.1% of the solid pixels (noise).
pub(crate) fn auto_palette(pix: &[Px], w: usize, h: usize, max_colors: usize) -> Vec<[u8; 3]> {
let mut px = solid_pixels(pix, w, h, 20.0);
if px.len() < max_colors.max(2) * 20 {
px = pix.iter().filter(|p| p[3] > 0.9).map(rgb255).collect();
}
if px.is_empty() {
return Vec::new();
}
if px.len() > 20_000 {
let stride = px.len() / 20_000 + 1;
px = px.into_iter().step_by(stride).collect();
}
let k = max_colors.clamp(1, 12).min(px.len());
let best = (0..4u64).map(|s| kmeans(&px, k, 0x9e37_79b9 + s * 7919)).min_by(|a, b| a.2.total_cmp(&b.2));
let Some((cent, lab, _)) = best else { return Vec::new() };
let mut cnt = vec![0usize; cent.len()];
for &l in &lab {
if let Some(c) = cnt.get_mut(l) {
*c += 1;
}
}
let total = lab.len().max(1);
let mut groups: Vec<([f64; 3], usize)> =
cent.iter().zip(&cnt).filter(|(c, n)| **n * 1000 > total && !c.iter().all(|&v| v >= f64::from(WHITE_MIN))).map(|(c, &n)| (*c, n)).collect();
// Centres closer than 20 are one colour split by noise.
loop {
let mut merged = false;
'outer: for i in 0..groups.len() {
for j in (i + 1)..groups.len() {
if dist2(groups[i].0, groups[j].0) < 400.0 {
let (a, b) = (groups[i], groups[j]);
let n = (a.1 + b.1) as f64;
let c = [0, 1, 2].map(|k| (a.0[k] * a.1 as f64 + b.0[k] * b.1 as f64) / n);
groups[i] = (c, a.1 + b.1);
groups.remove(j);
merged = true;
break 'outer;
}
}
}
if !merged {
break;
}
}
groups.sort_by_key(|g| std::cmp::Reverse(g.1));
groups.iter().map(|(c, _)| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
}
/// 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
/// pixels to be found at first. Groups of at least three such pixels at least 25 away from every
/// known colour are returned, most common first.
pub(crate) fn unexplained_colours(pix: &[Px], w: usize, h: usize, residual: &[f32], known: &[[u8; 3]]) -> Vec<[u8; 3]> {
let mut pal: Vec<[f64; 3]> = known.iter().map(|c| c.map(f64::from)).collect();
pal.push([255.0, 255.0, 255.0]);
let at = |x: usize, y: usize| pix.get(y * w + x).filter(|p| p[3] > 0.98).map(rgb255);
let mut groups: Vec<([f64; 3], usize)> = Vec::new();
for y in 1..h.saturating_sub(1) {
for x in 1..w.saturating_sub(1) {
if residual.get(y * w + x).copied().unwrap_or(0.0) < 0.0064 {
continue;
}
let Some(c) = at(x, y) else { continue };
let uniform = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)].iter().all(|&(xx, yy)| at(xx, yy).is_some_and(|q| dist2(q, c) < 100.0));
if !uniform || pal.iter().any(|q| dist2(*q, c) < 625.0) {
continue;
}
match groups.iter_mut().find(|(m, _)| dist2(*m, c) < 400.0) {
Some((m, n)) => {
for k in 0..3 {
m[k] = (m[k] * *n as f64 + c[k]) / (*n as f64 + 1.0);
}
*n += 1;
}
None => groups.push((c, 1)),
}
}
}
groups.retain(|g| g.1 >= 3);
groups.sort_by_key(|g| std::cmp::Reverse(g.1));
groups.iter().map(|(c, _)| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
}
/// Share of solid pixels within a small colour distance of a palette colour (white included).
/// About 1.0 for flat-colour art; far lower for gradients, shading and photos.
pub(crate) fn flat_fraction(pix: &[Px], w: usize, h: usize, palette: &[[u8; 3]]) -> f64 {
let px = solid_pixels(pix, w, h, 60.0);
if px.is_empty() {
return 0.0;
}
let mut pal: Vec<[f64; 3]> = palette.iter().map(|c| c.map(f64::from)).collect();
pal.push([255.0, 255.0, 255.0]);
let near = px.iter().filter(|p| pal.iter().any(|q| dist2(**p, *q) < 22.0 * 22.0)).count();
near as f64 / px.len() as f64
}
#[cfg(test)]
mod tests {
use super::*;
fn blocks(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<Px> {
(0..w * h)
.map(|i| {
let p = f(i % w, i / w);
let a = f32::from(p[3]) / 255.0;
[f32::from(p[0]) / 255.0 * a, f32::from(p[1]) / 255.0 * a, f32::from(p[2]) / 255.0 * a, a]
})
.collect()
}
#[test]
fn hex_parsing() {
assert_eq!(parse_hex("#6C60F6"), Some([0x6c, 0x60, 0xf6]));
assert_eq!(parse_hex("ea4335"), Some([0xea, 0x43, 0x35]));
for bad in ["", "#12345", "#1234567", "#gg0000", "é2345z", " "] {
assert_eq!(parse_hex(bad), None, "{bad:?}");
}
}
#[test]
fn three_flat_colours_are_found_exactly() {
let pix = blocks(60, 20, |x, _| match x / 20 {
0 => [200, 30, 30, 255],
1 => [30, 160, 60, 255],
_ => [60, 70, 180, 255],
});
let mut pal = auto_palette(&pix, 60, 20, 6);
pal.sort();
let mut want = vec![[200, 30, 30], [30, 160, 60], [60, 70, 180]];
want.sort();
assert_eq!(pal, want);
assert!(flat_fraction(&pix, 60, 20, &pal) > 0.99);
}
#[test]
fn white_is_never_in_the_palette_and_transparency_is_ignored() {
let pix = blocks(40, 20, |x, _| {
if x < 10 {
[255, 255, 255, 255]
} else if x < 20 {
[0, 0, 0, 0]
} else {
[10, 120, 200, 255]
}
});
let pal = auto_palette(&pix, 40, 20, 4);
assert_eq!(pal, vec![[10, 120, 200]]);
}
#[test]
fn a_gradient_is_not_flat() {
let pix = blocks(64, 16, |x, _| [(x * 4) as u8, 255 - (x * 4) as u8, 128, 255]);
let pal = auto_palette(&pix, 64, 16, 6);
assert!(flat_fraction(&pix, 64, 16, &pal) < 0.93, "gradient scored flat");
}
#[test]
fn nothing_to_find_in_empty_or_blank_images() {
assert!(auto_palette(&[], 0, 0, 6).is_empty());
let clear = blocks(10, 10, |_, _| [0, 0, 0, 0]);
assert!(auto_palette(&clear, 10, 10, 6).is_empty());
assert_eq!(flat_fraction(&clear, 10, 10, &[]), 0.0);
let white = blocks(10, 10, |_, _| [255, 255, 255, 255]);
assert!(auto_palette(&white, 10, 10, 6).is_empty());
}
}

View File

@ -0,0 +1,484 @@
//! The logo pipeline end to end: raster in, painted vector shapes out.
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::restore::RestoreParams;
use super::unmix::{Px, count_ambiguous, unmix};
use crate::{Raster, TraceError, TraceParams, TraceResult, TracedPath};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath};
/// Longest side the tracer works at; bigger images are averaged down first and the result scaled up.
const WORK_MAX_SIDE: usize = 1024;
/// Transparent border added around the image so every outline closes.
const PAD: usize = 3;
/// Most cells of the smooth field (about 24 MB per plane).
const FIELD_CELLS_MAX: usize = 6_000_000;
/// Flat-colour score below which the image is refused.
const MIN_FLAT: f64 = 0.93;
/// Most outlines of one colour (above the noise threshold) we will fit; more is refused, not truncated.
const MAX_LOOPS_PER_CLASS: usize = if cfg!(test) { 500 } else { 20_000 };
/// Most pixels `unmix` may have to work on (those that are not exactly one colour). A busier image is
/// averaged down further first, which bounds the time on pathological input.
const EDGE_BUDGET: usize = 150_000;
/// Palette colours closer than this (RGB distance) are one colour.
const SAME_COLOUR: f64 = 12.0;
/// Most colours a palette may hold (white and transparency come on top).
const MAX_COLOURS: usize = 10;
/// Neighbouring colours overlap by this many working pixels so no hairline gap can open.
const OVERLAP: f64 = 0.06;
/// The raster as premultiplied RGBA, averaged down so its longest side is at most [`WORK_MAX_SIDE`].
fn working_copy(img: &Raster, at_least: usize) -> (Vec<Px>, usize, usize, usize) {
let (w, h) = (img.width as usize, img.height as usize);
let factor = w.max(h).div_ceil(WORK_MAX_SIDE).max(at_least).max(1);
let (ow, oh) = (w.div_ceil(factor), h.div_ceil(factor));
let mut out = Vec::with_capacity(ow * oh);
for oy in 0..oh {
for ox in 0..ow {
let mut acc = [0.0f32; 4];
let mut n = 0.0f32;
for y in oy * factor..((oy + 1) * factor).min(h) {
for x in ox * factor..((ox + 1) * factor).min(w) {
let i = (y * w + x) * 4;
if let Some(p) = img.rgba.get(i..i + 4) {
let a = f32::from(p[3]) / 255.0;
acc[0] += f32::from(p[0]) / 255.0 * a;
acc[1] += f32::from(p[1]) / 255.0 * a;
acc[2] += f32::from(p[2]) / 255.0 * a;
acc[3] += a;
n += 1.0;
}
}
}
let n = n.max(1.0);
out.push([acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n]);
}
}
(out, ow, oh, factor)
}
fn style(params: &TraceParams) -> Style {
// Parameters come from users and agents: a NaN or infinity must not reach the geometry.
let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default };
let paths = unit(params.paths, 0.6);
let corners = unit(params.corners, 0.5);
Style {
tol: 0.04 + 0.2 * (1.0 - paths),
corner_deg: 90.0 - 55.0 * corners,
snap: params.snap_curves_to_lines,
restore: Some(RestoreParams::default()),
..Style::default()
}
}
/// Transparent regions fully enclosed by artwork (letters knocked out of a badge) become white, so
/// the logo is still right on a dark background. Works on coverage planes in place.
fn fill_knockouts(planes: &mut [Vec<f32>], w: usize, h: usize, white: usize, transparent: usize) {
let n = w * h;
let Some(t) = planes.get(transparent) else { return };
let seed: Vec<bool> = t.iter().take(n).map(|&v| v > 0.2).collect();
// Components of "mostly transparent" pixels; the ones touching the border are the outside.
let mut comp = vec![0u32; n];
let mut next_id = 0u32;
let mut outside: Vec<bool> = vec![false];
let mut stack = Vec::new();
for s in 0..n {
if !seed[s] || comp[s] != 0 {
continue;
}
next_id += 1;
let mut touches = false;
comp[s] = next_id;
stack.push(s);
while let Some(i) = stack.pop() {
let (x, y) = (i % w, i / w);
if x == 0 || y == 0 || x + 1 == w || y + 1 == h {
touches = true;
}
let mut push = |j: usize| {
if seed[j] && comp[j] == 0 {
comp[j] = next_id;
stack.push(j);
}
};
if x > 0 {
push(i - 1);
}
if x + 1 < w {
push(i + 1);
}
if y > 0 {
push(i - w);
}
if y + 1 < h {
push(i + w);
}
}
outside.push(touches);
}
let enclosed: Vec<bool> = comp.iter().map(|&c| c != 0 && !outside.get(c as usize).copied().unwrap_or(true)).collect();
// Grow by one pixel so the blended edge pixels around a knockout come with it.
let mut grown = enclosed.clone();
for y in 0..h {
for x in 0..w {
if enclosed[y * w + x] {
for yy in y.saturating_sub(1)..=(y + 1).min(h - 1) {
for xx in x.saturating_sub(1)..=(x + 1).min(w - 1) {
grown[yy * w + xx] = true;
}
}
}
}
}
for (i, &is_grown) in grown.iter().enumerate() {
if is_grown {
let tv = planes.get(transparent).and_then(|p| p.get(i)).copied().unwrap_or(0.0);
if tv > 0.02 {
if let Some(v) = planes.get_mut(white).and_then(|p| p.get_mut(i)) {
*v += tv;
}
if let Some(v) = planes.get_mut(transparent).and_then(|p| p.get_mut(i)) {
*v = 0.0;
}
}
}
}
}
fn pad_plane(plane: &[f32], w: usize, h: usize, outside: f32) -> Vec<f32> {
let (wp, hp) = (w + 2 * PAD, h + 2 * PAD);
let mut out = vec![outside; wp * hp];
for y in 0..h {
if let (Some(dst), Some(src)) = (out.get_mut((y + PAD) * wp + PAD..(y + PAD) * wp + PAD + w), plane.get(y * w..(y + 1) * w)) {
dst.copy_from_slice(src);
}
}
out
}
/// A loop's `(x, y)` grid points as working-pixel coordinates.
fn to_pixels(loop_pts: &[Point], u: usize) -> Vec<Point> {
loop_pts.iter().map(|p| Point::new((p.x + 0.5) / u as f64 - PAD as f64, (p.y + 0.5) / u as f64 - PAD as f64)).collect()
}
fn point_in_poly(p: Point, poly: &[Point]) -> bool {
let n = poly.len();
let mut inside = false;
for i in 0..n {
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
inside = !inside;
}
}
inside
}
/// What the working-resolution class map says about the pixels around an outline.
struct Around<'a> {
dominant: &'a [u8],
w: usize,
h: usize,
white: usize,
transparent: usize,
}
/// Grow the vertices of an outer outline that touch another coloured region.
fn grow_into_neighbours(p: &mut [Point], class: usize, map: &Around) {
let n = p.len();
if class == map.white || n < 3 {
return;
}
let src = p.to_vec();
for i in 0..n {
let (Some(&a), Some(&b), Some(&c)) = (src.get((i + n - 1) % n), src.get((i + 1) % n), src.get(i)) else { continue };
let t = b - a;
let len = t.hypot();
if len.is_nan() || len <= 1e-12 {
continue;
}
let t = t / len;
// Interior is on the left of travel, so the outward normal is on the right.
let out = kurbo::Vec2::new(-t.y, t.x);
let q = c + out * 0.45;
if q.x < 0.0 || q.y < 0.0 {
continue;
}
let (ix, iy) = (q.x as usize, q.y as usize);
if ix >= map.w || iy >= map.h {
continue;
}
let d = map.dominant.get(iy * map.w + ix).copied().map_or(map.transparent, usize::from);
if d != class
&& d != map.white
&& d != map.transparent
&& let Some(v) = p.get_mut(i)
{
*v = c + out * OVERLAP;
}
}
}
/// Where working-pixel coordinates land in the source image. Working pixels are `factor` source
/// pixels wide except the last in each direction, which may be narrower: mapping the whole axis
/// with one factor would overshoot the image by up to `factor - 1` pixels.
#[derive(Clone, Copy)]
struct ToSource {
factor: f64,
work: (usize, usize),
source: (usize, usize),
}
impl ToSource {
fn axis(&self, v: f64, work: usize, source: usize) -> f64 {
let last = work.saturating_sub(1) as f64;
if v <= last { v * self.factor } else { last * self.factor + (v - last) * (source as f64 - last * self.factor) }
}
fn point(&self, p: Point) -> Point {
Point::new(self.axis(p.x, self.work.0, self.source.0), self.axis(p.y, self.work.1, self.source.1))
}
}
fn to_subpath(segs: &[Cubic], map: &ToSource) -> Option<SubPath> {
let n = segs.len();
if n == 0 {
return None;
}
let sc = |p: Point| map.point(p);
let mut anchors = Vec::with_capacity(n);
for i in 0..n {
let (cur, prev) = (segs.get(i)?, segs.get((i + n - 1) % n)?);
let (p, h_out, h_in) = (sc(cur[0]), sc(cur[1]), sc(prev[2]));
let (vi, vo) = (p - h_in, h_out - p);
let kind = if vi.hypot() > 1e-9 && vo.hypot() > 1e-9 && vi.cross(vo).abs() < 0.07 * vi.hypot() * vo.hypot() && vi.dot(vo) > 0.0 {
AnchorKind::Smooth
} else {
AnchorKind::Corner
};
anchors.push(Anchor { p, h_in, h_out, kind });
}
anchors.iter().all(|a| finite(a.p) && finite(a.h_in) && finite(a.h_out)).then(|| SubPath::new(anchors, true))
}
struct Shape {
class: usize,
area: f64,
outer: Vec<Cubic>,
holes: Vec<Vec<Cubic>>,
}
/// A hard-edged source (a GIF, say) has no anti-aliasing to read an edge position from, and its
/// outlines would follow the pixel staircase. Blurring the coverage by under a pixel gives the
/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone.
fn soften_if_aliased(planes: &mut [Vec<f32>], w: usize, h: usize) -> bool {
let n = w * h;
let dominant: Vec<usize> =
(0..n).map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(planes, a, i).total_cmp(&plane_at(planes, b, i))).unwrap_or(0)).collect();
let (mut boundary, mut blended) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let d = dominant.get(i).copied().unwrap_or(0);
let strongest = (0..planes.len()).map(|c| plane_at(planes, c, i)).fold(0.0f32, f32::max);
if strongest < 0.9 {
blended += 1;
}
let differs = |j: usize| dominant.get(j).is_some_and(|&o| o != d);
if (x + 1 < w && differs(i + 1)) || (y + 1 < h && differs(i + w)) {
boundary += 1;
}
}
}
let aliased = boundary > 0 && blended * 4 < boundary;
if aliased {
for p in planes.iter_mut() {
blur_plane(p, w, h, 0.8);
}
}
aliased
}
fn plane_at(planes: &[Vec<f32>], class: usize, i: usize) -> f32 {
planes.get(class).and_then(|p| p.get(i)).copied().unwrap_or(0.0)
}
/// The unmixing classes for a palette: each colour, then white, then transparent.
fn classes_for(palette: &[[u8; 3]]) -> Vec<[f64; 4]> {
let mut classes: Vec<[f64; 4]> =
palette.iter().map(|c| [f64::from(c[0]) / 255.0, f64::from(c[1]) / 255.0, f64::from(c[2]) / 255.0, 1.0]).collect();
classes.push([1.0, 1.0, 1.0, 1.0]);
classes.push([0.0, 0.0, 0.0, 0.0]);
classes
}
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()
}
/// 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> {
if params.logo_colors.is_empty() {
return Ok(auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS)));
}
let mut palette: Vec<[u8; 3]> = Vec::new();
for s in &params.logo_colors {
// Every entry is checked, but only the first few distinct colours are kept.
let c = parse_hex(s).ok_or_else(|| TraceError::BadPalette(s.clone()))?;
let white = c.iter().all(|&v| v >= WHITE_MIN);
if palette.len() < MAX_COLOURS && !white && !palette.iter().any(|&q| distance(q, c) < SAME_COLOUR) {
palette.push(c);
}
}
Ok(palette)
}
/// Trace a flat-colour logo.
pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
let (w0, h0) = (img.width as usize, img.height as usize);
if w0 == 0 || h0 == 0 || img.rgba.len() != w0 * h0 * 4 {
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)?;
if palette.is_empty() {
return Ok(TraceResult::default());
}
let fraction = flat_fraction(&pix, w, h, &palette);
if fraction < MIN_FLAT {
return Err(TraceError::NotFlat { fraction });
}
let mut classes = classes_for(&palette);
// A very busy image is averaged down further until the unmixing work is bounded.
for _ in 0..6 {
if count_ambiguous(&pix, &classes) <= EDGE_BUDGET || w * h <= 16_384 {
break;
}
(pix, w, h, factor) = working_copy(img, factor * 2);
}
let (mut planes, residual) = unmix(&pix, w, h, &classes);
let mut planes = if params.logo_colors.is_empty() && palette.len() < MAX_COLOURS {
// Colours too small to be found at first show up as pixels nothing explains: add them.
let extra = unexplained_colours(&pix, w, h, &residual, &palette);
if extra.is_empty() {
planes
} else {
palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len()));
classes = classes_for(&palette);
unmix(&pix, w, h, &classes).0
}
} else {
std::mem::take(&mut planes)
};
let (white, transparent) = (palette.len(), palette.len() + 1);
let aliased = soften_if_aliased(&mut planes, w, h);
fill_knockouts(&mut planes, w, h, white, transparent);
let n = w * h;
let dominant: Vec<u8> = (0..n)
.map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(&planes, a, i).total_cmp(&plane_at(&planes, b, i))).unwrap_or(transparent) as u8)
.collect();
let around = Around { dominant: &dominant, w, h, white, transparent };
// Smooth fields: where each class is the most present, and by how much.
let (wp, hp) = (w + 2 * PAD, h + 2 * PAD);
let u = (((FIELD_CELLS_MAX / (wp * hp).max(1)) as f64).sqrt() as usize).clamp(2, 8);
let (ow, oh) = (wp * u, hp * u);
let padded: Vec<Vec<f32>> = planes.iter().enumerate().map(|(c, p)| pad_plane(p, w, h, if c == transparent { 1.0 } else { 0.0 })).collect();
let mut best = vec![f32::MIN; ow * oh];
let mut second = vec![f32::MIN; ow * oh];
let mut best_id = vec![0u8; ow * oh];
for (c, pl) in padded.iter().enumerate() {
let m = upsample(pl, wp, hp, u);
if m.len() != ow * oh {
return Ok(TraceResult::default());
}
for (i, &v) in m.iter().enumerate() {
if v > best[i] {
second[i] = best[i];
best[i] = v;
best_id[i] = c as u8;
} else if v > second[i] {
second[i] = v;
}
}
}
let mut style = style(params);
if aliased {
// No anti-aliasing means the edge position is only known to about half a pixel.
style.tol *= 2.5;
style.circle_tol *= 1.8;
style.line_tol *= 1.8;
style.sigma = 0.9;
}
// `noise` is in source pixels (areas under noise/10 are ignored), whatever the working size.
let min_area = ((f64::from(params.noise) / 10.0) / (factor * factor) as f64).max(0.3);
let mut stats = Stats::default();
let mut shapes: Vec<Shape> = Vec::new();
let mut anchors_made = 0usize;
for class in 0..=white {
if class == white && params.ignore_white {
continue;
}
let m = upsample(padded.get(class).map_or(&[][..], Vec::as_slice), wp, hp, u);
let f: Vec<f32> = m.iter().enumerate().map(|(i, &v)| v - if usize::from(best_id[i]) == class { second[i] } else { best[i] }).collect();
drop(m);
let mut outers: Vec<(f64, Vec<Point>)> = Vec::new();
let mut holes: Vec<Vec<Point>> = Vec::new();
for lp in contours(&f, ow, oh) {
let a2 = area2(&lp) / (u * u) as f64;
if a2.abs() / 2.0 < min_area {
continue;
}
// More real outlines than we will fit is refused, not silently truncated.
if outers.len() + holes.len() >= MAX_LOOPS_PER_CLASS {
return Err(TraceError::TooComplex { max: max_anchors });
}
let pts = to_pixels(&lp, u);
if a2 < 0.0 { outers.push((a2.abs() / 2.0, pts)) } else { holes.push(pts) }
}
// Each hole belongs to the smallest outline that contains it, and to no other.
let owner: Vec<Option<usize>> = if params.ignore_white {
holes
.iter()
.map(|hole| {
let p = *hole.first()?;
outers.iter().enumerate().filter(|(_, (_, o))| point_in_poly(p, o)).min_by(|a, b| a.1.0.total_cmp(&b.1.0)).map(|(i, _)| i)
})
.collect()
} else {
Vec::new()
};
for (idx, (area, mut pts)) in outers.into_iter().enumerate() {
grow_into_neighbours(&mut pts, class, &around);
let Some(outer) = fit_outline(&pts, &style, &mut stats) else { continue };
let hole_fits: Vec<Vec<Cubic>> =
holes.iter().zip(&owner).filter(|(_, o)| **o == Some(idx)).filter_map(|(hole, _)| fit_outline(hole, &style, &mut stats)).collect();
anchors_made += outer.len() + hole_fits.iter().map(Vec::len).sum::<usize>();
if anchors_made > max_anchors {
return Err(TraceError::TooComplex { max: max_anchors });
}
shapes.push(Shape { class, area, outer, holes: hole_fits });
}
}
// Painter's order: biggest shapes first, so smaller ones sit on top of what they belong to.
shapes.sort_by(|a, b| b.area.total_cmp(&a.area).then(a.class.cmp(&b.class)));
let to_source = ToSource { factor: factor as f64, work: (w, h), source: (w0, h0) };
let mut out = TraceResult::default();
for s in shapes {
let color = palette.get(s.class).copied().unwrap_or([255, 255, 255]);
let mut subs: Vec<SubPath> = Vec::new();
subs.extend(to_subpath(&s.outer, &to_source));
for hole in &s.holes {
subs.extend(to_subpath(hole, &to_source));
}
if subs.is_empty() {
continue;
}
if !out.palette.contains(&color) {
out.palette.push(color);
}
out.paths.push(TracedPath { path: PathData::new(subs), color, pixels: (s.area * (factor * factor) as f64) as usize, stroke: None });
}
Ok(out)
}

View File

@ -0,0 +1,205 @@
//! Corner restoration. Blur rounds every sharp corner a little. For each bend, fit a line to the
//! edge on either side (skipping the rounded zone), intersect the two lines, and put the corner
//! back at the intersection when it lies close to the bend.
use super::geom::{line_fit, turning};
use vectorcraft_geom::Point;
#[derive(Clone, Copy, Debug)]
pub(crate) struct RestoreParams {
/// Turning angle (degrees, over ~1 px) a bend must reach to be a candidate.
pub turn: f64,
/// Rounded zone skipped either side of the bend, in pixels.
pub l1: f64,
/// Edge length used for the line fit, measured from the bend, in pixels.
pub l2: f64,
/// Largest distance any edge sample may be from its fitted line.
pub line_res: f64,
/// Smallest angle between the two edges for a corner (degrees).
pub min_turn: f64,
/// Farthest the restored corner may be from the rounded bend, in pixels.
pub corner_dist: f64,
}
impl Default for RestoreParams {
fn default() -> Self {
Self { turn: 35.0, l1: 1.3, l2: 3.5, line_res: 0.15, min_turn: 30.0, corner_dist: 1.3 }
}
}
/// `r` with each restorable bend replaced by one corner vertex, and the indices of those vertices.
/// `s0` is a lightly smoothed copy of `r` (same length) used to find bends.
pub(crate) fn restore_corners(r: &[Point], s0: &[Point], step: f64, p: &RestoreParams) -> (Vec<Point>, Vec<usize>) {
let n = r.len();
if n < 16 || s0.len() != n || step.is_nan() || step <= 0.0 {
return (r.to_vec(), Vec::new());
}
let w = ((1.0 / step).round() as usize).max(3);
let ang = turning(s0, w);
let sup = (1.5 / step).round() as usize;
let circ = |a: usize, b: usize| (a + n - b) % n;
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| ang.get(b).copied().unwrap_or(0.0).total_cmp(&ang.get(a).copied().unwrap_or(0.0)));
let mut cand: Vec<usize> = Vec::new();
// Candidates suppress their neighbours within `sup` samples: a bitmap keeps this linear.
let mut blocked = vec![false; n];
for i in order {
if ang.get(i).copied().unwrap_or(0.0) < p.turn {
break;
}
if blocked.get(i).copied().unwrap_or(true) {
continue;
}
cand.push(i);
for d in 0..=sup.min(n / 2) {
for j in [(i + d) % n, (i + n - d) % n] {
if let Some(b) = blocked.get_mut(j) {
*b = true;
}
}
}
}
cand.sort_unstable();
let l1 = (p.l1 / step).round() as usize;
let l2_full = (p.l2 / step).round() as usize;
let mut accepted: Vec<(usize, Point)> = Vec::new();
for (ci, &i) in cand.iter().enumerate() {
let (prev, next) = (cand[(ci + cand.len() - 1) % cand.len()], cand[(ci + 1) % cand.len()]);
let (room_l, room_r) = if cand.len() > 1 { (circ(i, prev), circ(next, i)) } else { (n, n) };
let l2 = l2_full.min(room_l / 2 + l1 / 2).min(room_r / 2 + l1 / 2);
if l2 < l1 + ((0.8 / step).round() as usize) || l2 >= n / 2 {
continue;
}
let pick = |from: usize, to: usize, back: bool| -> Vec<Point> {
(from..=to).filter_map(|k| s0.get(if back { (i + n - k) % n } else { (i + k) % n }).copied()).collect()
};
let mut left = pick(l1, l2, true);
left.reverse();
let right = pick(l1, l2, false);
let (Some(la), Some(lb)) = (line_fit(&left), line_fit(&right)) else { continue };
if la.resid > p.line_res || lb.resid > p.line_res {
continue;
}
let turn_deg = la.d.dot(lb.d).clamp(-1.0, 1.0).acos().to_degrees();
if turn_deg < p.min_turn || turn_deg > 160.0 {
continue;
}
let cr = la.d.cross(lb.d);
if cr.abs() < 1e-6 {
continue;
}
let s = (lb.c - la.c).cross(lb.d) / cr;
let x = la.c + la.d * s;
let Some(&here) = r.get(i) else { continue };
if !(x.x.is_finite() && x.y.is_finite()) || x.distance(here) > p.corner_dist {
continue;
}
// Neighbouring corners may not share replaced stretches.
if let Some(&(prev_i, _)) = accepted.last()
&& circ(i, prev_i) <= 2 * l1 + 1
{
continue;
}
accepted.push((i, x));
}
if let (Some(&(first, _)), Some(&(last, _))) = (accepted.first(), accepted.last())
&& accepted.len() > 1
&& circ(first, last) <= 2 * l1 + 1
{
accepted.pop();
}
if accepted.is_empty() {
return (r.to_vec(), Vec::new());
}
let mut skip = vec![false; n];
let mut ins: Vec<Option<Point>> = vec![None; n];
for &(i, x) in &accepted {
for t in 0..=2 * l1 {
if let Some(s) = skip.get_mut((i + n - l1 + t) % n) {
*s = true;
}
}
if let Some(slot) = ins.get_mut((i + n - l1) % n) {
*slot = Some(x);
}
}
let mut out = Vec::with_capacity(n);
let mut forced = Vec::new();
for (i, &q) in r.iter().enumerate() {
if let Some(Some(x)) = ins.get(i) {
forced.push(out.len());
out.push(*x);
}
if !skip.get(i).copied().unwrap_or(false) {
out.push(q);
}
}
(out, forced)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logo::geom::{resample_closed, smooth_closed};
use crate::logo::testutil::rounded_square;
#[test]
fn a_rounded_square_gets_its_four_corners_back() {
let (r, step) = resample_closed(&rounded_square(0.7), 0.12).expect("resample");
let s0 = smooth_closed(&r, 0.18 / step);
let (out, forced) = restore_corners(&r, &s0, step, &RestoreParams::default());
assert_eq!(forced.len(), 4, "corners restored: {forced:?}");
for (cx, cy) in [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)] {
let near = forced.iter().map(|&i| out[i].distance(Point::new(cx, cy))).fold(f64::MAX, f64::min);
assert!(near < 0.1, "corner ({cx}, {cy}) restored {near} px off");
}
assert!(out.len() < r.len());
}
#[test]
fn a_smooth_circle_restores_nothing() {
let circle: Vec<Point> = (0..800)
.map(|i| {
Point::new(
20.0 + 9.0 * (i as f64 / 800.0 * std::f64::consts::TAU).cos(),
20.0 + 9.0 * (i as f64 / 800.0 * std::f64::consts::TAU).sin(),
)
})
.collect();
let (r, step) = resample_closed(&circle, 0.12).expect("resample");
let s0 = smooth_closed(&r, 0.18 / step);
let (out, forced) = restore_corners(&r, &s0, step, &RestoreParams::default());
assert!(forced.is_empty());
assert_eq!(out.len(), r.len());
}
#[test]
fn a_gentle_bend_is_not_a_corner() {
// Two straight edges meeting at 20 degrees: below the minimum turn.
let mut pts = Vec::new();
for i in 0..200 {
pts.push(Point::new(i as f64 * 0.1, 0.0));
}
for i in 1..200 {
let a = 20f64.to_radians();
pts.push(Point::new(20.0 + i as f64 * 0.1 * a.cos(), i as f64 * 0.1 * a.sin()));
}
for i in 0..300 {
pts.push(Point::new(40.0 - i as f64 * 0.13, 6.8 + i as f64 * 0.01));
}
let (r, step) = resample_closed(&pts, 0.12).expect("resample");
let s0 = smooth_closed(&r, 0.18 / step);
let _ = restore_corners(&r, &s0, step, &RestoreParams { min_turn: 30.0, ..RestoreParams::default() });
}
#[test]
fn degenerate_input_is_returned_unchanged() {
let few = vec![Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0)];
let (o, f) = restore_corners(&few, &few, 0.12, &RestoreParams::default());
assert_eq!((o.len(), f.len()), (3, 0));
let (o, f) = restore_corners(&few, &[], 0.12, &RestoreParams::default());
assert_eq!((o.len(), f.len()), (3, 0));
let (_, f) = restore_corners(&few, &few, 0.0, &RestoreParams::default());
assert!(f.is_empty());
}
}

View File

@ -0,0 +1,415 @@
//! End-to-end tests: synthetic logos made in code, with known geometry, traced and measured.
use crate::{Method, Mode, Raster, TraceError, TraceParams, TraceResult, trace_within};
use kurbo::{ParamCurve, Shape};
use vectorcraft_geom::Point;
/// A soft, anti-aliased render of `shade` (`Some(colour)` where the shape is), supersampled `ss × ss`
/// and blurred by `blur` pixels, on a transparent background.
fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]>) -> Raster {
let ss = 8usize;
let (wu, hu) = (w as usize, h as usize);
let mut px = vec![[0.0f64; 4]; wu * hu];
for y in 0..hu {
for x in 0..wu {
let mut acc = [0.0f64; 4];
for sy in 0..ss {
for sx in 0..ss {
let (fx, fy) = (x as f64 + (sx as f64 + 0.5) / ss as f64, y as f64 + (sy as f64 + 0.5) / ss as f64);
if let Some(c) = shade(fx, fy) {
acc[0] += f64::from(c[0]) / 255.0;
acc[1] += f64::from(c[1]) / 255.0;
acc[2] += f64::from(c[2]) / 255.0;
acc[3] += 1.0;
}
}
}
let n = (ss * ss) as f64;
px[y * wu + x] = [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n];
}
}
if blur > 0.0 {
let r = (3.0 * blur).ceil() as i64;
let k: Vec<f64> = (-r..=r).map(|i| (-0.5 * (i as f64 / blur).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
let k: Vec<f64> = k.iter().map(|v| v / sum).collect();
for horizontal in [true, false] {
let src = px.clone();
for y in 0..hu as i64 {
for x in 0..wu as i64 {
let mut acc = [0.0f64; 4];
for (j, kv) in k.iter().enumerate() {
let d = j as i64 - r;
let (xx, yy) = if horizontal { ((x + d).clamp(0, wu as i64 - 1), y) } else { (x, (y + d).clamp(0, hu as i64 - 1)) };
let p = src[(yy as usize) * wu + xx as usize];
for c in 0..4 {
acc[c] += kv * p[c];
}
}
px[(y as usize) * wu + x as usize] = acc;
}
}
}
}
let mut rgba = Vec::with_capacity(wu * hu * 4);
for p in px {
let a = p[3];
let un = |v: f64| if a > 1e-9 { ((v / a) * 255.0).round().clamp(0.0, 255.0) as u8 } else { 0 };
rgba.extend_from_slice(&[un(p[0]), un(p[1]), un(p[2]), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
}
Raster::new(w, h, rgba)
}
const RED: [u8; 3] = [210, 40, 50];
const BLUE: [u8; 3] = [40, 80, 200];
fn logo_params() -> TraceParams {
TraceParams {
mode: Mode::Logo,
colors: 6,
paths: 60.0,
corners: 50.0,
noise: 6,
snap_curves_to_lines: true,
method: Method::Abutting,
..TraceParams::default()
}
}
fn trace(img: &Raster, p: &TraceParams) -> TraceResult {
trace_within(img, p, 100_000).expect("trace")
}
fn samples(res: &TraceResult) -> Vec<Point> {
let mut out = Vec::new();
for p in &res.paths {
for seg in p.path.to_bezpath().segments() {
out.extend((0..=40).map(|i| seg.eval(f64::from(i) / 40.0)));
}
}
out
}
fn covered(res: &TraceResult, p: Point) -> bool {
res.paths.iter().any(|t| t.path.to_bezpath().winding(p) != 0)
}
fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
(0..3).all(|i| (i32::from(a[i]) - i32::from(b[i])).abs() <= tol)
}
#[test]
fn a_blurred_disc_becomes_a_true_circle_within_a_tenth_of_a_pixel() {
let (cx, cy, r) = (31.7, 32.4, 19.3);
let img = render(64, 64, 0.7, |x, y| ((x - cx).hypot(y - cy) <= r).then_some(RED));
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1, "{} paths", res.paths.len());
assert!(near(res.paths[0].color, RED, 3), "colour {:?}", res.paths[0].color);
let errs: Vec<f64> = samples(&res).iter().map(|p| (p.distance(Point::new(cx, cy)) - r).abs()).collect();
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
let max = errs.iter().copied().fold(0.0, f64::max);
assert!(mean < 0.06 && max < 0.15, "mean {mean:.3} max {max:.3}");
assert!(res.anchor_count() <= 6, "{} anchors for a circle", res.anchor_count());
}
#[test]
fn a_blurred_rectangle_keeps_square_corners_and_four_anchors() {
let (x0, x1, y0, y1) = (10.4, 77.9, 8.7, 38.2);
let img = render(90, 48, 0.8, |x, y| ((x0..=x1).contains(&x) && (y0..=y1).contains(&y)).then_some(BLUE));
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1);
assert_eq!(res.anchor_count(), 4, "a rectangle is four corners");
let b = res.paths[0].path.bounds().expect("bounds");
for (got, want, what) in [(b.x0, x0, "left"), (b.x1, x1, "right"), (b.y0, y0, "top"), (b.y1, y1, "bottom")] {
// Sub-pixel: within 0.15 px of the true edge, from a blurred 90 px image.
assert!((got - want).abs() < 0.15, "{what} edge {got:.3} vs {want}");
}
}
#[test]
fn touching_colours_leave_no_gap_between_them() {
let edge = 40.35;
let img = render(80, 50, 0.7, |x, y| {
if !(10.2..=40.6).contains(&y) {
None
} else if (10.3..=edge).contains(&x) {
Some(RED)
} else if (edge..=70.6).contains(&x) {
Some(BLUE)
} else {
None
}
});
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 2);
assert!(!near(res.paths[0].color, res.paths[1].color, 20));
for i in 0..200 {
let y = 12.0 + 27.0 * f64::from(i) / 199.0;
for dx in [-0.03, 0.0, 0.03] {
assert!(covered(&res, Point::new(edge + dx, y)), "gap at ({}, {y})", edge + dx);
}
}
}
#[test]
fn letters_knocked_out_of_a_badge_become_white_or_holes() {
let ring = |x: f64, y: f64| {
let d = (x - 32.0).hypot(y - 32.0);
(12.0..=25.0).contains(&d).then_some(BLUE)
};
let img = render(64, 64, 0.7, ring);
// Default: the knockout is painted white, on top of the badge.
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 2, "badge + white knockout");
assert!(res.paths.iter().any(|p| p.color == [255, 255, 255]));
assert!(covered(&res, Point::new(32.0, 32.0)));
// Ignore white: the knockout is a real hole in the badge.
let res = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert_eq!(res.paths.len(), 1);
assert_eq!(res.paths[0].path.subpaths.len(), 2, "outer outline and a hole");
assert!(!covered(&res, Point::new(32.0, 32.0)), "the hole must be empty");
assert!(covered(&res, Point::new(32.0, 32.0 + 18.0)), "the ring itself is filled");
}
#[test]
fn a_gradient_is_refused_not_posterised() {
let img = Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]);
match trace_within(&img, &logo_params(), 100_000) {
Err(TraceError::NotFlat { fraction }) => assert!(fraction < 0.93),
other => panic!("expected NotFlat, got {other:?}"),
}
let msg = TraceError::NotFlat { fraction: 0.69 }.to_string();
assert!(msg.contains("69%") && msg.contains("Color mode"), "{msg}");
}
#[test]
fn a_white_background_logo_drops_the_white_when_asked() {
let img = Raster::from_fn(80, 60, |x, y| {
let d = (x as f64 + 0.5 - 40.0).hypot(y as f64 + 0.5 - 30.0);
if (10.0..=24.0).contains(&d) { [20, 20, 20, 255] } else { [255, 255, 255, 255] }
});
let kept = trace(&img, &logo_params());
assert!(kept.paths.iter().any(|p| p.color == [255, 255, 255]), "white background kept by default");
let dropped = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert!(dropped.paths.iter().all(|p| p.color != [255, 255, 255]));
assert!(!dropped.paths.is_empty());
}
#[test]
fn the_named_palette_is_used_exactly_and_a_bad_one_is_an_error() {
let img = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(RED));
let named = TraceParams { logo_colors: vec!["#d22832".into()], ..logo_params() };
let res = trace(&img, &named);
assert_eq!(res.paths[0].color, [0xd2, 0x28, 0x32]);
let bad = TraceParams { logo_colors: vec!["red".into()], ..logo_params() };
assert!(matches!(trace_within(&img, &bad, 1000), Err(TraceError::BadPalette(s)) if s == "red"));
}
#[test]
fn a_large_image_is_traced_at_reduced_size_and_scaled_back() {
let (cx, cy, r) = (1200.0, 600.0, 400.0);
let img = Raster::from_fn(2400, 1200, |x, y| {
if (f64::from(x) + 0.5 - cx).hypot(f64::from(y) + 0.5 - cy) <= r { [RED[0], RED[1], RED[2], 255] } else { [0, 0, 0, 0] }
});
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1);
let b = res.paths[0].path.bounds().expect("bounds");
for (got, want) in [(b.x0, cx - r), (b.x1, cx + r), (b.y0, cy - r), (b.y1, cy + r)] {
assert!((got - want).abs() < 2.0, "{got} vs {want}");
}
}
/// Every anchor of every path is finite and inside (a small margin around) the image.
fn assert_sane(res: &TraceResult, w: u32, h: u32) {
for t in &res.paths {
for sp in &t.path.subpaths {
for a in &sp.anchors {
assert!(a.p.x.is_finite() && a.p.y.is_finite() && a.h_in.x.is_finite() && a.h_out.y.is_finite(), "non-finite anchor");
assert!(a.p.x > -2.0 && a.p.y > -2.0 && a.p.x < f64::from(w) + 2.0 && a.p.y < f64::from(h) + 2.0, "anchor {:?} outside {w}x{h}", a.p);
}
}
}
}
#[test]
fn degenerate_images_are_fine() {
let p = logo_params();
for (w, h, img) in [
(0, 0, Raster::new(0, 0, vec![])),
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255])),
(3, 3, Raster::new(3, 3, vec![0; 36])),
(2, 2, Raster::new(2, 2, vec![255; 16])),
(5, 1, Raster::new(5, 1, vec![9; 20])),
(1, 7, Raster::new(1, 7, vec![130; 28])),
] {
match trace_within(&img, &p, 1000) {
Ok(res) => assert_sane(&res, w, h),
Err(TraceError::NotFlat { .. }) => {}
Err(e) => panic!("unexpected error for {w}x{h}: {e}"),
}
}
}
#[test]
fn extreme_parameters_never_panic() {
let img = render(48, 48, 0.7, |x, y| ((x - 24.0).hypot(y - 24.0) <= 15.0).then_some(RED));
let weird = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -50.0, 0.0, 100.0, 1e9];
for &paths in &weird {
for &corners in &weird {
let p = TraceParams { paths, corners, ..logo_params() };
let _ = trace_within(&img, &p, 10_000);
}
}
for colors in [0, 1, 2, 300, u32::MAX] {
for noise in [0, 1, 1_000_000, u32::MAX] {
let p = TraceParams { colors, noise, ..logo_params() };
let _ = trace_within(&img, &p, 10_000);
}
}
// A tiny anchor budget is reported as too complex, not exceeded.
let r = trace_within(&img, &logo_params(), 0);
assert!(r.is_err() || r.is_ok_and(|t| t.anchor_count() == 0));
}
#[test]
fn noise_specks_are_dropped() {
let img = render(64, 64, 0.5, |x, y| if (x - 32.0).hypot(y - 32.0) <= 18.0 || (x - 6.0).hypot(y - 6.0) <= 0.6 { Some(RED) } else { None });
let res = trace(&img, &TraceParams { noise: 20, ..logo_params() });
assert_eq!(res.paths.len(), 1, "the speck should be gone");
}
#[test]
fn nested_rings_keep_their_own_holes_under_ignore_white() {
// Red ring, white band, red ring, white centre.
let img = render(80, 80, 0.7, |x, y| {
let d = (x - 40.0).hypot(y - 40.0);
((22.0..=28.0).contains(&d) || (8.0..=16.0).contains(&d)).then_some(RED)
});
let res = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert_eq!(res.paths.len(), 2, "two red rings");
for p in &res.paths {
assert_eq!(p.path.subpaths.len(), 2, "each ring is an outline and its own hole, no more");
}
assert!(!covered(&res, Point::new(40.0, 40.0)), "centre is empty");
assert!(covered(&res, Point::new(52.0, 40.0)), "inner ring is filled");
assert!(!covered(&res, Point::new(59.0, 40.0)), "the band between the rings is empty");
assert!(covered(&res, Point::new(65.0, 40.0)), "outer ring is filled");
}
#[test]
fn too_many_outlines_are_refused_not_silently_truncated() {
let specks =
|x: f64, y: f64| (x.rem_euclid(5.0) >= 1.0 && x.rem_euclid(5.0) < 4.0 && y.rem_euclid(5.0) >= 1.0 && y.rem_euclid(5.0) < 4.0).then_some(RED);
let img = render(200, 200, 0.0, specks);
assert!(matches!(trace_within(&img, &logo_params(), 1_000_000), Err(TraceError::TooComplex { .. })));
// Specks under the noise threshold do not count towards the limit.
let res = trace_within(&img, &TraceParams { noise: 200, ..logo_params() }, 1_000_000).expect("trace");
assert!(res.paths.is_empty());
}
#[test]
fn a_large_odd_sized_image_never_overshoots_its_edges() {
// 1025 wide: the last working pixel is narrower than the others.
let img = Raster::from_fn(1025, 20, |x, y| if x >= 600 && (2..18).contains(&y) { [RED[0], RED[1], RED[2], 255] } else { [0, 0, 0, 0] });
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1);
let b = res.paths[0].path.bounds().expect("bounds");
assert!(b.x1 <= 1025.15 && b.x1 >= 1024.7, "right edge {}", b.x1);
assert!((b.x0 - 600.0).abs() < 0.6 && (b.y0 - 2.0).abs() < 0.6 && (b.y1 - 18.0).abs() < 0.6, "{b:?}");
assert_sane(&res, 1025, 20);
}
#[test]
fn a_hostile_palette_is_bounded_and_near_duplicates_merge() {
let img = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(RED));
let many: Vec<String> = std::iter::once("#d22832".to_string()).chain((1..200_000u32).map(|i| format!("#{:06x}", i * 37 % 0xfffff0))).collect();
let started = std::time::Instant::now();
let res = trace_within(&img, &TraceParams { logo_colors: many, ..logo_params() }, 100_000);
assert!(started.elapsed().as_secs() < 20, "palette handling took {:?}", started.elapsed());
if let Ok(r) = res {
assert!(r.palette.len() <= 11);
}
// Ten shades within a couple of units of one red are one colour, so one path, not ten layers.
let shades: Vec<String> = (0..10).map(|i| format!("#{:02x}{:02x}{:02x}", 210 + i % 3, 40 + i % 2, 50 + i % 3)).collect();
let res = trace(&img, &TraceParams { logo_colors: shades, ..logo_params() });
assert_eq!(res.paths.len(), 1, "{} paths", res.paths.len());
// White cannot be named: naming only white leaves nothing to trace.
assert!(trace(&img, &TraceParams { logo_colors: vec!["#ffffff".into()], ..logo_params() }).paths.is_empty());
}
#[test]
fn a_small_accent_colour_is_not_lost() {
let img = render(100, 100, 0.6, |x, y| {
if (x - 50.0).hypot(y - 50.0) <= 40.0 {
Some(BLUE)
} else if (x - 90.0).hypot(y - 90.0) <= 3.5 {
Some(RED)
} else {
None
}
});
let res = trace(&img, &logo_params());
assert!(res.paths.iter().any(|p| near(p.color, RED, 12)), "palette {:?}", res.palette);
assert!(res.paths.iter().any(|p| near(p.color, BLUE, 12)));
}
#[test]
fn a_hard_edged_source_still_gets_smooth_circles() {
// No anti-aliasing at all: every pixel is exactly one colour.
let img = Raster::from_fn(80, 80, |x, y| {
let d = (f64::from(x) + 0.5 - 40.0).hypot(f64::from(y) + 0.5 - 40.0);
if (10.0..=28.0).contains(&d) { [RED[0], RED[1], RED[2], 255] } else { [0, 0, 0, 0] }
});
let res = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert!(res.anchor_count() <= 40, "{} anchors for two circles from a hard-edged source", res.anchor_count());
let errs: Vec<f64> = samples(&res)
.iter()
.map(|p| {
let d = p.distance(Point::new(40.0, 40.0));
(d - 28.0).abs().min((d - 10.0).abs())
})
.collect();
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
assert!(mean < 0.3 && errs.iter().copied().fold(0.0, f64::max) < 0.8, "mean {mean:.3}");
}
#[test]
fn random_small_images_and_settings_never_panic() {
let mut state = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let weird = [f64::NAN, f64::INFINITY, -3.0, 0.0, 35.0, 60.0, 100.0, 1e12];
let colours = [[210u8, 40, 50], [40, 80, 200], [255, 255, 255], [20, 160, 70], [0, 0, 0]];
for _ in 0..250 {
let (w, h) = ((next() % 24 + 1) as u32, (next() % 24 + 1) as u32);
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let r = next();
let c = colours[((u64::from(x) / 3 + u64::from(y) / 4 + r % 2) % 5) as usize];
rgba.extend_from_slice(&match r % 11 {
0 => [0, 0, 0, 0],
1 => [(r >> 8) as u8, (r >> 16) as u8, (r >> 24) as u8, (r >> 32) as u8],
_ => [c[0], c[1], c[2], 255],
});
}
}
let img = Raster::new(w, h, rgba);
let p = TraceParams {
paths: weird[(next() % 8) as usize],
corners: weird[(next() % 8) as usize],
noise: [0, 1, 6, 1000, u32::MAX][(next() % 5) as usize],
colors: [0, 2, 6, 40][(next() % 4) as usize],
ignore_white: next() % 2 == 0,
snap_curves_to_lines: next() % 2 == 0,
..logo_params()
};
if let Ok(res) = trace_within(&img, &p, 20_000) {
assert_sane(&res, w, h);
}
}
}

View File

@ -0,0 +1,25 @@
//! Shared synthetic shapes for tests (no brand artwork: everything is made here from scratch).
use vectorcraft_geom::Point;
/// A square from (10, 10) to (30, 30) with corners rounded to radius `rad`, as a dense loop.
pub(crate) fn rounded_square(rad: f64) -> Vec<Point> {
let mut pts = Vec::new();
let corners = [(30.0 - rad, 10.0 + rad, -90.0), (30.0 - rad, 30.0 - rad, 0.0), (10.0 + rad, 30.0 - rad, 90.0), (10.0 + rad, 10.0 + rad, 180.0)];
for (cx, cy, start) in corners {
for k in 0..=24 {
let a = (start + 90.0 * k as f64 / 24.0).to_radians();
pts.push(Point::new(cx + rad * a.cos(), cy + rad * a.sin()));
}
}
pts
}
/// A circle of radius `r` about (`cx`, `cy`) with `n` points.
pub(crate) fn circle(cx: f64, cy: f64, r: f64, n: usize) -> Vec<Point> {
(0..n)
.map(|i| {
Point::new(cx + r * (i as f64 / n as f64 * std::f64::consts::TAU).cos(), cy + r * (i as f64 / n as f64 * std::f64::consts::TAU).sin())
})
.collect()
}

View File

@ -0,0 +1,223 @@
//! Soft coverage: how much of each flat colour (and of transparency) every pixel contains.
//!
//! An edge pixel of an anti-aliased logo is a blend of the colours that meet there. Reading it as
//! "the nearest colour" throws away exactly the sub-pixel information that says where the edge
//! runs, so each pixel is instead explained as a mix of at most three classes, whichever mix
//! reproduces the pixel best.
/// Premultiplied RGBA in `0..=1`.
pub(crate) type Px = [f32; 4];
const LAMBDA: f64 = 30.0;
/// Pixels this close (in 0..1 premultiplied RGBA) to a class are taken as exactly that class.
const PURE: f64 = 4.0 / 255.0;
struct Subset {
idx: [usize; 3],
n: usize,
/// `(AᵀA + λ²·11ᵀ)⁻¹`, row-major `n × n`.
ninv: [f64; 9],
cols: [[f64; 4]; 3],
penalty: f64,
}
fn inv2(m: [f64; 4]) -> Option<[f64; 4]> {
let det = m[0] * m[3] - m[1] * m[2];
(det.abs() > 1e-9).then(|| [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det])
}
fn inv3(m: [f64; 9]) -> Option<[f64; 9]> {
let c = [
m[4] * m[8] - m[5] * m[7],
m[2] * m[7] - m[1] * m[8],
m[1] * m[5] - m[2] * m[4],
m[5] * m[6] - m[3] * m[8],
m[0] * m[8] - m[2] * m[6],
m[2] * m[3] - m[0] * m[5],
m[3] * m[7] - m[4] * m[6],
m[1] * m[6] - m[0] * m[7],
m[0] * m[4] - m[1] * m[3],
];
let det = m[0] * c[0] + m[1] * c[3] + m[2] * c[6];
(det.abs() > 1e-9).then(|| {
let d = 1.0 / det;
[c[0] * d, c[1] * d, c[2] * d, c[3] * d, c[4] * d, c[5] * d, c[6] * d, c[7] * d, c[8] * d]
})
}
fn subsets(classes: &[[f64; 4]]) -> Vec<Subset> {
let k = classes.len();
let mut out = Vec::new();
let mut push = |idx: [usize; 3], n: usize, penalty: f64| {
let mut cols = [[0.0; 4]; 3];
for (slot, &i) in cols.iter_mut().zip(idx.iter()).take(n) {
*slot = classes.get(i).copied().unwrap_or([0.0; 4]);
}
let mut nm = [0.0f64; 9];
for a in 0..n {
for b in 0..n {
let dot: f64 = (0..4).map(|c| cols[a][c] * cols[b][c]).sum();
nm[a * n + b] = dot + LAMBDA * LAMBDA;
}
}
let ninv = match n {
2 => inv2([nm[0], nm[1], nm[2], nm[3]]).map(|m| {
let mut o = [0.0; 9];
o[..4].copy_from_slice(&m);
o
}),
3 => inv3(nm),
_ => None,
};
if let Some(ninv) = ninv {
out.push(Subset { idx, n, ninv, cols, penalty });
}
};
for a in 0..k {
for b in (a + 1)..k {
push([a, b, 0], 2, 4e-4);
}
}
for a in 0..k {
for b in (a + 1)..k {
for c in (b + 1)..k {
push([a, b, c], 3, 1.2e-3);
}
}
}
out
}
/// How many pixels are not (nearly) exactly one class: the ones `unmix` has to work on.
pub(crate) fn count_ambiguous(pix: &[Px], classes: &[[f64; 4]]) -> usize {
pix.iter()
.filter(|px| {
let p = [f64::from(px[0]), f64::from(px[1]), f64::from(px[2]), f64::from(px[3])];
!classes.iter().any(|cv| (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum::<f64>().sqrt() < PURE)
})
.count()
}
/// Coverage planes, one `width × height` plane per class (each pixel's weights sum to 1).
pub(crate) fn unmix(pix: &[Px], width: usize, height: usize, classes: &[[f64; 4]]) -> (Vec<Vec<f32>>, Vec<f32>) {
let k = classes.len();
let n = width.saturating_mul(height);
let mut planes = vec![vec![0.0f32; n]; k];
// Squared error left over by the best mix: large where no mix of the classes explains the pixel.
let mut residual = vec![0.0f32; n];
let subs = subsets(classes);
for (i, px) in pix.iter().take(n).enumerate() {
let p = [f64::from(px[0]), f64::from(px[1]), f64::from(px[2]), f64::from(px[3])];
// Fast path: a pixel that is (nearly) exactly one class.
let mut near = (f64::MAX, 0usize);
for (c, cv) in classes.iter().enumerate() {
let d: f64 = (0..4).map(|j| (p[j] - cv[j]).powi(2)).sum();
if d < near.0 {
near = (d, c);
}
}
if near.0.sqrt() < PURE {
if let Some(v) = planes.get_mut(near.1).and_then(|pl| pl.get_mut(i)) {
*v = 1.0;
}
continue;
}
// Best single class is the starting point; try every two- and three-class mix.
let mut best = (near.0, vec![(near.1, 1.0f64)]);
let mut best_penalty = 0.0;
for s in &subs {
let mut rhs = [0.0f64; 3];
for (j, r) in rhs.iter_mut().enumerate().take(s.n) {
*r = (0..4).map(|c| s.cols[j][c] * p[c]).sum::<f64>() + LAMBDA * LAMBDA;
}
let mut w = [0.0f64; 3];
for (a, wa) in w.iter_mut().enumerate().take(s.n) {
*wa = (0..s.n).map(|b| s.ninv[a * s.n + b] * rhs[b]).sum();
}
if w.iter().take(s.n).any(|&x| x < -0.02) {
continue;
}
let mut sum = 0.0;
for wa in w.iter_mut().take(s.n) {
*wa = wa.clamp(0.0, 1.0);
sum += *wa;
}
if sum < 1e-9 {
continue;
}
for wa in w.iter_mut().take(s.n) {
*wa /= sum;
}
let mut res = s.penalty;
for (c, &pc) in p.iter().enumerate() {
let m: f64 = (0..s.n).map(|j| w[j] * s.cols[j][c]).sum();
res += (pc - m).powi(2);
}
if res < best.0 {
best = (res, (0..s.n).map(|j| (s.idx[j], w[j])).collect());
best_penalty = s.penalty;
}
}
if let Some(r) = residual.get_mut(i) {
*r = (best.0 - best_penalty).max(0.0) as f32;
}
for (c, wv) in best.1 {
if let Some(v) = planes.get_mut(c).and_then(|pl| pl.get_mut(i)) {
*v = wv as f32;
}
}
}
(planes, residual)
}
#[cfg(test)]
mod tests {
use super::*;
fn classes() -> Vec<[f64; 4]> {
vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]]
}
#[test]
fn pure_pixels_are_exact() {
let pix = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0]];
let (pl, _) = unmix(&pix, 2, 1, &classes());
assert_eq!((pl[0][0], pl[2][1]), (1.0, 1.0));
}
#[test]
fn a_blend_of_two_colours_recovers_its_proportions() {
// 30% red over 70% blue.
let pix = vec![[0.3, 0.0, 0.7, 1.0]];
let (pl, _) = unmix(&pix, 1, 1, &classes());
assert!((pl[0][0] - 0.3).abs() < 0.02, "red {}", pl[0][0]);
assert!((pl[1][0] - 0.7).abs() < 0.02, "blue {}", pl[1][0]);
}
#[test]
fn a_half_transparent_edge_is_half_covered() {
// Red at 40% coverage over nothing: premultiplied (0.4, 0, 0, 0.4).
let pix = vec![[0.4, 0.0, 0.0, 0.4]];
let (pl, _) = unmix(&pix, 1, 1, &classes());
assert!((pl[0][0] - 0.4).abs() < 0.02 && (pl[2][0] - 0.6).abs() < 0.02, "{:?}", (pl[0][0], pl[2][0]));
}
#[test]
fn a_colour_no_class_can_explain_leaves_a_large_residual() {
// Green is none of red, blue or transparent, nor any blend of them.
let pix = vec![[0.0, 0.8, 0.0, 1.0], [0.3, 0.0, 0.7, 1.0]];
let (_, res) = unmix(&pix, 2, 1, &classes());
assert!(res[0] > 0.1, "unexplained pixel residual {}", res[0]);
assert!(res[1] < 1e-3, "an explained blend residual {}", res[1]);
}
#[test]
fn weights_sum_to_one() {
let pix = vec![[0.2, 0.1, 0.5, 0.8], [0.9, 0.0, 0.05, 0.95]];
let (pl, _) = unmix(&pix, 2, 1, &classes());
for i in 0..2 {
let s: f32 = pl.iter().map(|p| p[i]).sum();
assert!((s - 1.0).abs() < 1e-4, "pixel {i} sums to {s}");
}
}
}

View File

@ -55,7 +55,8 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
});
compact(labels, palette.collect())
}
Mode::Color => {
// Logo mode traces without quantising; asked directly, it quantises like Color.
Mode::Color | Mode::Logo => {
// 15-bit colour histogram with per-bin sums (exact bin means).
let mut bins: Vec<Bin> = vec![Bin::default(); 1 << 15];
for p in img.rgba.as_chunks::<4>().0 {

View File

@ -220,13 +220,14 @@ fn snap_curves_to_lines_straightens_near_lines() {
#[test]
fn presets_resolve() {
assert_eq!(presets().len(), 12);
assert_eq!(presets().len(), 13);
for n in PRESET_NAMES {
assert!(preset(n).is_some(), "{n}");
}
assert!(preset("[Default]").is_some());
assert_eq!(preset("16 colors").unwrap().colors, 16);
assert_eq!(preset("Shades of Gray").unwrap().mode, Mode::Grayscale);
assert_eq!(preset("flat logo").unwrap().mode, Mode::Logo);
assert!(preset("nope").is_none());
}
@ -256,6 +257,10 @@ fn decode_png() {
fn all_presets_trace_a_colour_image() {
let img = Raster::from_fn(64, 64, |x, y| [(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8, 255]);
for (name, p) in presets() {
// Flat Logo refuses a gradient on purpose (see logo::tests); every other preset traces it.
if p.mode == Mode::Logo {
continue;
}
let r = trace(&img, &p);
assert!(!r.paths.is_empty() || p.ignore_white, "{name}");
for tp in &r.paths {

View File

@ -975,6 +975,7 @@
Flare Tool Herramienta Destello
Flare Tool Options Opciones de la herramienta Destello
Flat Image Imagen acoplada
Flat Logo Logotipo plano
Flatness: Lisura:
Flatten Artwork Acoplar ilustración
Flatten Transparency Acoplar transparencia
@ -3640,6 +3641,8 @@ axis Both Ambos
@msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas
@msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo
@msg x and y must both be numbers x e y deben ser números
@msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servicios

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

View File

@ -974,6 +974,7 @@
Flare Tool Outil Halo
Flare Tool Options Options de l’outil Halo
Flat Image Image aplatie
Flat Logo Logo en aplats
Flatness: Planéité :
Flatten Artwork Aplatir l’illustration
Flatten Transparency Aplatir la transparence
@ -3637,6 +3638,8 @@ axis Both Les deux
@msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils
@msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe
@msg x and y must both be numbers x et y doivent tous deux être des nombres
@msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Services

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

View File

@ -974,6 +974,7 @@
Flare Tool Strumento Riflesso
Flare Tool Options Opzioni strumento Riflesso
Flat Image Immagine appiattita
Flat Logo Logo piatto
Flatness: Planarità:
Flatten Artwork Appiattisci grafica
Flatten Transparency Appiattisci trasparenza
@ -3640,6 +3641,8 @@ axis Both Entrambi
@msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti
@msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo
@msg x and y must both be numbers x e y devono essere entrambi numeri
@msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servizi

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

View File

@ -972,6 +972,7 @@
Flare Tool Инструмент «Блик»
Flare Tool Options Параметры инструмента «Блик»
Flat Image Плоское изображение
Flat Logo Плоский логотип
Flatness: Плоскостность:
Flatten Artwork Объединить рисунок
Flatten Transparency Объединить прозрачности
@ -3636,6 +3637,8 @@ axis Both Оба
@msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов
@msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе
@msg x and y must both be numbers x и y должны быть числами
@msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет»
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Службы

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

View File

@ -13,7 +13,7 @@ use super::{first_selected, library_panel, pstate, set_pstate, swatches};
use crate::VectorcraftApp;
use crate::widgets::{self, menu_item};
const MODES: [(&str, &str); 3] = [("blackAndWhite", "Black and White"), ("grayscale", "Grayscale"), ("color", "Color")];
const MODES: [(&str, &str); 4] = [("blackAndWhite", "Black and White"), ("grayscale", "Grayscale"), ("color", "Color"), ("logo", "Flat Logo")];
/// Color mode's palettes (`palette` param ids), in panel order.
const PALETTES: [&str; 4] = ["limited", "fullTone", "automatic", "documentLibrary"];
@ -273,6 +273,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let (key, label, range, suffix) = match mode.as_str() {
"blackAndWhite" => ("threshold", tl!("Threshold"), 0.0..=255.0, ""),
"grayscale" => ("colors", tl!("Grays"), 2.0..=256.0, ""),
// Flat Logo finds a handful of flat colours: more than ten is almost always noise.
"logo" => ("colors", tl!("Colors"), 2.0..=10.0, ""),
_ if matches!(palette.as_str(), "fullTone" | "automatic") => ("colorDetail", tl!("Colors"), 0.0..=100.0, "%"),
_ => ("colors", tl!("Colors"), 2.0..=256.0, ""),
};
@ -301,37 +303,40 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
retrace |= release;
}
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Method:"));
for (m, l) in [("abutting", tl!("Abutting")), ("overlapping", tl!("Overlapping"))] {
if ui.selectable_label(st.params["method"] == m, l).clicked() && st.params["method"] != m {
st.params["method"] = json!(m);
st.preset = "Custom".into();
retrace = true;
// Flat Logo always paints biggest shapes first with a hair of overlap, so there is no choice.
if st.params["mode"] != "logo" {
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Method:"));
for (m, l) in [("abutting", tl!("Abutting")), ("overlapping", tl!("Overlapping"))] {
if ui.selectable_label(st.params["method"] == m, l).clicked() && st.params["method"] != m {
st.params["method"] = json!(m);
st.preset = "Custom".into();
retrace = true;
}
}
}
});
// Create: Fills and/or Strokes (one of them stays on), and the widest line stroked.
let create = |p: &Value, key: &str| p[key].as_bool().unwrap_or(key == "fills");
let strokes = create(&st.params, "strokes");
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Create:"));
for (key, other, label) in [("fills", "strokes", tl!("Fills")), ("strokes", "fills", tl!("Strokes"))] {
let on = create(&st.params, key);
if widgets::check(ui, label, on, !on || create(&st.params, other)) {
st.params[key] = json!(!on);
st.preset = "Custom".into();
retrace = true;
});
// Create: Fills and/or Strokes (one of them stays on), and the widest line stroked.
let create = |p: &Value, key: &str| p[key].as_bool().unwrap_or(key == "fills");
let strokes = create(&st.params, "strokes");
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Create:"));
for (key, other, label) in [("fills", "strokes", tl!("Fills")), ("strokes", "fills", tl!("Strokes"))] {
let on = create(&st.params, key);
if widgets::check(ui, label, on, !on || create(&st.params, other)) {
st.params[key] = json!(!on);
st.preset = "Custom".into();
retrace = true;
}
}
});
let mut v = st.params["strokeWidth"].as_f64().unwrap_or(10.0);
let (changed, release) = ui.add_enabled_ui(strokes, |ui| slider(ui, tl!("Stroke"), &mut v, 1.0..=100.0, " px")).inner;
if changed {
st.params["strokeWidth"] = json!(v.round());
st.preset = "Custom".into();
}
});
let mut v = st.params["strokeWidth"].as_f64().unwrap_or(10.0);
let (changed, release) = ui.add_enabled_ui(strokes, |ui| slider(ui, tl!("Stroke"), &mut v, 1.0..=100.0, " px")).inner;
if changed {
st.params["strokeWidth"] = json!(v.round());
st.preset = "Custom".into();
retrace |= release;
}
retrace |= release;
for (key, label) in [("snapCurvesToLines", tl!("Snap Curves To Lines")), ("ignoreWhite", tl!("Ignore White"))] {
let on = st.params[key].as_bool().unwrap_or(false);
if widgets::check(ui, label, on, true) {