From 29310175af37acabe09e83ef7d438d3b77769e15 Mon Sep 17 00:00:00 2001 From: Jax Spenser <258931473+jax-spenser@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:41:46 +1100 Subject: [PATCH 1/4] Image Trace: Flat Logo mode (sub-pixel edges, restored corners, true lines and circles) A fourth Image Trace mode for flat-colour logos. The existing tracer 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 (cubic B-spline), and every outline is placed where two colours are equally present, to a fraction of a pixel. Outlines are then cleaned the way a person would: - corners the blur rounded are put back (line fits either side of a bend, intersected); - near-straight runs and near-circular arcs become exact lines and arcs (Snap Curves To Lines); - the rest is fitted with as few cubic Beziers as the tolerance allows (Schneider); - touching colours overlap by 0.06 px so no hairline gap opens between independent fits; - shapes are painted biggest first; transparent areas 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, or become real holes with Ignore White (each hole belongs to the smallest outline containing it); - an outline whose fit strays more than 1.4 px from the raw contour is fitted plainly. Colours are found automatically (and completed from pixels no blend of the known colours explains, so a small accent is not lost) or named with `logoColors: ["#rrggbb", ...]` (white is always its own class; colours closer than 12 count as one; at most ten). Gradients and photos are refused with advice to use Color (a flat-colour score of the solid areas, 0.93). A hard-edged source with no anti-aliasing is softened by under a pixel and fitted with looser snap tolerances. Images over 1024 px on the long side are averaged down first (further, if they are busy enough to make the unmixing expensive) and the result is mapped back exactly. More real outlines than we will fit are refused, not silently truncated. Also: a "Flat Logo" preset, the mode in the Image Trace panel (Method hidden: it always stacks), the command docs and MCP prompt, es/fr/it/ru translations for the two new messages and the mode name, and the ROADMAP. Never-crash: no unwrap/expect/panic, every size and float is bounded and checked, with tests for degenerate and extreme input (NaN/infinite parameters, 0x0 and 1x1 images, sparse outlines, an anchor budget, a 200,000-entry palette) and a 250-run randomised no-panic property test. The B-spline interpolation follows Unser et al. and Thevenaz et al., and the curve fitter follows Schneider (Graphics Gems); both are written from the papers. Measured (local experiment, not in CI): a flat-colour logo's vector original rendered down to a soft 199x100 px PNG and traced. Mean edge error vs the original at 1600 px wide: built-in colour presets 2.5 to 3.4 px, Flat Logo 0.78 px (0.48 px from a crisp source); region overlap 0.88-0.91 vs 0.966. About 0.1 s for a 200 px logo. Tests: 60 new in the trace crate (unmix, field, contours, geometry, Beziers, corners, outline fitting, palette, end-to-end on synthetic shapes made in code) and 3 in the engine. cargo xtask ci passes (fmt, clippy, test, assets, brands, layers, wasm): 4,290 tests. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 3 +- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/tests_build.rs | 51 ++- crates/mcp/src/prompts.rs | 5 +- crates/trace/src/lib.rs | 28 +- crates/trace/src/logo/bezier.rs | 196 +++++++++ crates/trace/src/logo/field.rs | 228 +++++++++++ crates/trace/src/logo/geom.rs | 394 ++++++++++++++++++ crates/trace/src/logo/march.rs | 175 ++++++++ crates/trace/src/logo/mod.rs | 27 ++ crates/trace/src/logo/outline.rs | 306 ++++++++++++++ crates/trace/src/logo/palette.rs | 287 ++++++++++++++ crates/trace/src/logo/pipeline.rs | 484 +++++++++++++++++++++++ crates/trace/src/logo/restore.rs | 205 ++++++++++ crates/trace/src/logo/tests.rs | 415 +++++++++++++++++++ crates/trace/src/logo/testutil.rs | 25 ++ crates/trace/src/logo/unmix.rs | 223 +++++++++++ crates/trace/src/quantize.rs | 3 +- crates/trace/src/tests.rs | 7 +- crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/panels/image_trace.rs | 61 +-- 24 files changed, 3098 insertions(+), 39 deletions(-) create mode 100644 crates/trace/src/logo/bezier.rs create mode 100644 crates/trace/src/logo/field.rs create mode 100644 crates/trace/src/logo/geom.rs create mode 100644 crates/trace/src/logo/march.rs create mode 100644 crates/trace/src/logo/mod.rs create mode 100644 crates/trace/src/logo/outline.rs create mode 100644 crates/trace/src/logo/palette.rs create mode 100644 crates/trace/src/logo/pipeline.rs create mode 100644 crates/trace/src/logo/restore.rs create mode 100644 crates/trace/src/logo/tests.rs create mode 100644 crates/trace/src/logo/testutil.rs create mode 100644 crates/trace/src/logo/unmix.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6c85780e0..3b93ede7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,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 `` 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 `` 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. @@ -185,7 +186,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 ``/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 | diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 7b6fe170b..ef3a10fe4 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "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) ), diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 5ddc1c195..a79f00484 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -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(); diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index a1fa2f4e7..2dff4486e 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -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 diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 71b673dc5..488031dd9 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -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, } 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 { "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. diff --git a/crates/trace/src/logo/bezier.rs b/crates/trace/src/logo/bezier.rs new file mode 100644 index 000000000..d2119117f --- /dev/null +++ b/crates/trace/src/logo/bezier.rs @@ -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 { + 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 { + 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 { + 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) { + 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 { + 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 = (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 = (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 = (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 = (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); + } +} diff --git a/crates/trace/src/logo/field.rs b/crates/trace/src/logo/field.rs new file mode 100644 index 000000000..dc277adec --- /dev/null +++ b/crates/trace/src/logo/field.rs @@ -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 = (-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 { + let mut c: Vec = 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 { + 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 = (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 = (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()); + } +} diff --git a/crates/trace/src/logo/geom.rs b/crates/trace/src/logo/geom.rs new file mode 100644 index 000000000..5b26160ac --- /dev/null +++ b/crates/trace/src/logo/geom.rs @@ -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, 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 { + let s = sigma.max(0.3); + let r = ((3.0 * s).ceil() as i64).max(1); + let k: Vec = (-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 { + 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 { + 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 = 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 = (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 { + let k: Vec = (-(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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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> = 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 { + (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 = (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 = (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 = (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 = (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)); + } +} diff --git a/crates/trace/src/logo/march.rs b/crates/trace/src/logo/march.rs new file mode 100644 index 000000000..ca5c7aab3 --- /dev/null +++ b/crates/trace/src/logo/march.rs @@ -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> { + if w < 2 || h < 2 || f.len() < w * h { + return Vec::new(); + } + let mut next: HashMap = 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 = 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 { + (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 = (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 = 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 = (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()); + } +} diff --git a/crates/trace/src/logo/mod.rs b/crates/trace/src/logo/mod.rs new file mode 100644 index 000000000..1729107aa --- /dev/null +++ b/crates/trace/src/logo/mod.rs @@ -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; diff --git a/crates/trace/src/logo/outline.rs b/crates/trace/src/logo/outline.rs new file mode 100644 index 000000000..c8ff7e89f --- /dev/null +++ b/crates/trace/src/logo/outline.rs @@ -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, + /// 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 { + 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> { + 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 = 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 = 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 = (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> { + 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::() + }; + 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); + } +} diff --git a/crates/trace/src/logo/palette.rs b/crates/trace/src/logo/palette.rs new file mode 100644 index 000000000..5bd31ddba --- /dev/null +++ b/crates/trace/src/logo/palette.rs @@ -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, 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 = 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 { + (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()); + } +} diff --git a/crates/trace/src/logo/pipeline.rs b/crates/trace/src/logo/pipeline.rs new file mode 100644 index 000000000..1f73608bf --- /dev/null +++ b/crates/trace/src/logo/pipeline.rs @@ -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, 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], w: usize, h: usize, white: usize, transparent: usize) { + let n = w * h; + let Some(t) = planes.get(transparent) else { return }; + let seed: Vec = 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 = 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 = 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 { + 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 { + 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 { + 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, + holes: Vec>, +} + +/// 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], w: usize, h: usize) -> bool { + let n = w * h; + let dominant: Vec = + (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], 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::().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, 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 ¶ms.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 { + 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 = (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> = 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 = 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 = 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)> = Vec::new(); + let mut holes: Vec> = 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> = 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> = + 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::(); + 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 = 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) +} diff --git a/crates/trace/src/logo/restore.rs b/crates/trace/src/logo/restore.rs new file mode 100644 index 000000000..984a073f6 --- /dev/null +++ b/crates/trace/src/logo/restore.rs @@ -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, Vec) { + 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 = (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 = 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 { + (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> = 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 = (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()); + } +} diff --git a/crates/trace/src/logo/tests.rs b/crates/trace/src/logo/tests.rs new file mode 100644 index 000000000..b3178baa5 --- /dev/null +++ b/crates/trace/src/logo/tests.rs @@ -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 = (-r..=r).map(|i| (-0.5 * (i as f64 / blur).powi(2)).exp()).collect(); + let sum: f64 = k.iter().sum(); + let k: Vec = 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 { + 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 = samples(&res).iter().map(|p| (p.distance(Point::new(cx, cy)) - r).abs()).collect(); + let mean = errs.iter().sum::() / 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 = 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 = (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 = 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::() / 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); + } + } +} diff --git a/crates/trace/src/logo/testutil.rs b/crates/trace/src/logo/testutil.rs new file mode 100644 index 000000000..660c11151 --- /dev/null +++ b/crates/trace/src/logo/testutil.rs @@ -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 { + 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 { + (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() +} diff --git a/crates/trace/src/logo/unmix.rs b/crates/trace/src/logo/unmix.rs new file mode 100644 index 000000000..16aff8c5b --- /dev/null +++ b/crates/trace/src/logo/unmix.rs @@ -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 { + 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::().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) { + 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::() + 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}"); + } + } +} diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 96de7897e..0460ef629 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -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 = vec![Bin::default(); 1 << 15]; for p in img.rgba.as_chunks::<4>().0 { diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 39b5838c1..dfc22aa59 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -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 { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index de6bb819c..91e6a8633 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -958,6 +958,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 @@ -3623,6 +3624,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44e111387..b5dd753e8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -957,6 +957,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 @@ -3620,6 +3621,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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7029cd47a..4c9c40891 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -957,6 +957,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 @@ -3623,6 +3624,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ddfcf3ef..d947ff62c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -955,6 +955,7 @@ Flare Tool Инструмент «Блик» Flare Tool Options Параметры инструмента «Блик» Flat Image Плоское изображение + Flat Logo Плоский логотип Flatness: Плоскостность: Flatten Artwork Объединить рисунок Flatten Transparency Объединить прозрачности @@ -3619,6 +3620,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 Службы diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 095c7d2a8..f3d5cf7f3 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -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) { From cdea4ff593ce3e6ae6ae904ab41deffa8b8704ff Mon Sep 17 00:00:00 2001 From: callmeBalloch <12576795+callmeBalloch@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:02:33 +0000 Subject: [PATCH 2/4] AF.3: say which sample set the Affinity semantics were fitted on The README said 176 public documents in its provenance and 189 in its limits. The thumbnail fitting was done on 176; the collection has since grown to 189 distinct documents, and the reader opens all of them (298 files with duplicates, no failures). State both. PhotoCraft's copy of the reader got the same fix in storytold/photocraft#1607. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC --- crates/affinity/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index d7a40f9c5..0a1bd1f2c 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -68,7 +68,8 @@ of CC0 sample files. * **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows, - macOS and iPad. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others + macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens + all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others (no licence stated, or GPL or non-commercial terms) were only opened locally to compare pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned files, which are CC0, MIT or Apache-2.0 only. @@ -111,7 +112,7 @@ Every size is checked before it is allocated: 256 MiB per archive entry and 1 Gi default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of -189 distinct public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve +those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient stops. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. From 806e543fd18bb28e7b2029a49aec0452b8e1844e Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:26:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Type:=20Kinsoku=20Set=20=E2=80=94=20Hard=20?= =?UTF-8?q?(today's=20set),=20Soft=20and=20None=20(#633)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paragraph panel › Kinsoku Set (with the East Asian options) and `text.setFormat {kinsoku}`, as decided in #633: - Hard: today's set, so existing documents don't change; the default, and not saved. - Soft: as Hard, except that 々, the prolonged sound mark ー (JLREQ cl-10) and small kana (cl-11) may start a line: JLREQ's level 3 line-breaking rules (Appendix C.3). Half-width forms aren't in those classes and stay off the line start. - None: no kinsoku. The setting reaches every break the composers consider (greedy, every-line and hyphenation points). Reset Panel returns to Hard. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/lib.rs | 6 +-- crates/doc/src/text.rs | 24 +++++++++ crates/engine/src/cmd/panelcmds.rs | 15 +++++- crates/engine/src/cmd/typecmd.rs | 30 +++++++++++ crates/text/src/layout.rs | 69 +++++++++++++++++--------- crates/text/src/shape.rs | 35 +++++++++++++ crates/text/src/tests_vertical.rs | 29 +++++++++++ crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/it.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/ru.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/src/panels/paragraph.rs | 18 ++++++- docs/mcp.md | 13 +++++ 17 files changed, 233 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ab1d4a221..ae4f29e6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,7 +146,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **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 `` 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. +- **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 applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a 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 `` and PDF soft masks. - **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):** @@ -219,7 +219,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 24% | ~52 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur and Unsharp Mask are done | 26–40 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | -| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | | Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9dd9b0764..e84c44bf6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -86,9 +86,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ - AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, LeadingModel, Mojikumi, - ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, - VerticalAlign, WrapShape, + AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel, + Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, + TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use vectorcraft_color as color; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8b69dda83..ec9ca824c 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -341,6 +341,9 @@ pub struct ParaStyle { /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before /// it and imported text keep [`Burasagari::None`]. pub burasagari: Burasagari, + /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a + /// line. + pub kinsoku: Kinsoku, } /// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph @@ -378,6 +381,8 @@ struct ParaStyleFile { leading_model: LeadingModel, #[serde(default, skip_serializing_if = "crate::skip::is_default")] burasagari: Burasagari, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kinsoku: Kinsoku, } impl From for ParaStyleFile { @@ -402,6 +407,7 @@ impl From for ParaStyleFile { direction, leading_model, burasagari, + kinsoku, .. } = p; Self { @@ -420,6 +426,7 @@ impl From for ParaStyleFile { direction, leading_model, burasagari, + kinsoku, } } } @@ -441,6 +448,7 @@ impl From for ParaStyle { direction, leading_model, burasagari, + kinsoku, .. } = f; Self { @@ -458,6 +466,7 @@ impl From for ParaStyle { direction, leading_model, burasagari, + kinsoku, } } } @@ -504,6 +513,21 @@ impl Mojikumi { } } +/// Which Japanese characters may not start or end a line (kinsoku shori). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kinsoku { + /// No kinsoku: a line may break between any two characters that allow a break. + None, + /// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small + /// kana and the like don't start a line; opening brackets don't end one. + #[default] + Hard, + /// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line + /// (JLREQ's level 3 line-breaking rules, Appendix C.3). + Soft, +} + /// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) /// stands outside the line's measure, in the space its punctuation spacing gives it (half width /// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index fa64b596b..c8568d9d2 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -218,6 +218,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "spaceAfter", "hyphenate", "mojikumi", + "kinsoku", "direction", "leadingModel", "charAlign", @@ -245,6 +246,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")), }), }; + let kinsoku = match p.get("kinsoku") { + None => None, + Some(v) => Some(match v.as_str() { + Some("none") => vectorcraft_doc::Kinsoku::None, + Some("hard") => vectorcraft_doc::Kinsoku::Hard, + Some("soft") => vectorcraft_doc::Kinsoku::Soft, + _ => return Err(bad(C, "`kinsoku` must be \"none\", \"hard\" or \"soft\"")), + }), + }; let direction = match p.get("direction") { None => None, Some(v) => Some(match v.as_str() { @@ -333,6 +343,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = mojikumi { para.mojikumi = v; } + if let Some(v) = kinsoku { + para.kinsoku = v; + } if let Some(v) = direction { para.direction = v; } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6b9961b55..7604e908b 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -642,6 +642,36 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { mod area_tests { use super::*; + #[test] + fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { + use vectorcraft_doc::Kinsoku; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + // New type: Hard, today's set. + assert_eq!(para(&s).kinsoku, Kinsoku::Hard); + assert!(serde_json::to_value(para(&s)).unwrap().get("kinsoku").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"kinsoku": "strict"})).is_err()); + s.execute("text.setFormat", &json!({"kinsoku": "soft"})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::Soft); + assert_eq!(serde_json::to_value(para(&s)).unwrap()["kinsoku"], "soft"); + s.execute("text.setFormat", &json!({"kinsoku": "none"})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::None); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).kinsoku, Kinsoku::Hard); + // Documents from before it read as Hard. + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.kinsoku, Kinsoku::Hard); + } + #[test] fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { use vectorcraft_doc::Burasagari; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index ba73c2109..6181d2d59 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -5,14 +5,16 @@ use std::ops::Range; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::{ - Burasagari, CharStyle, InlineArt, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, + Burasagari, CharStyle, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points; -use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics}; +use crate::shape::{ + Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, soft_line_start, style_metrics, +}; use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; const EPS: f64 = 1e-6; @@ -999,7 +1001,7 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S /// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. /// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. -fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari) -> (usize, bool) { +fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari, kinsoku: Kinsoku) -> (usize, bool) { if !width.is_finite() { return (g.len(), false); } @@ -1015,7 +1017,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b let cluster_width: f64 = g[j..cluster_end].iter().map(|glyph| glyph.adv).sum(); if j > i && !gl.is_space() && x + cluster_width > width + EPS { // It ends the line with the spaces after it. - if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1) { + if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { let mut end = cluster_end; while g.get(end).is_some_and(SGlyph::is_space) { end += 1; @@ -1026,7 +1028,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b } x += cluster_width; let last = &g[cluster_end - 1]; - if last.break_after() && kinsoku_allows(g, cluster_end - 1) { + if last.break_after() && kinsoku_allows(g, cluster_end - 1, kinsoku) { if last.is_soft_hyphen() { if x + hyphen_glyph(last).adv <= width + EPS { last_break = Some((cluster_end, true)); @@ -1048,7 +1050,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b we += 1; } let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum(); - let pts: Vec = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)).collect(); + let pts: Vec = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)).collect(); for &k in pts.iter().rev() { if k <= j && k > i { let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; @@ -1077,13 +1079,19 @@ fn hangs(g: &SGlyph) -> bool { /// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a /// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line /// with the one before it). -fn kinsoku_allows(g: &[SGlyph], j: usize) -> bool { - g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch))) +fn kinsoku_allows(g: &[SGlyph], j: usize, kinsoku: Kinsoku) -> bool { + g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch), kinsoku)) } -/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph). -fn kinsoku_between(before: char, after: Option) -> bool { - !no_line_end(before) && after.is_none_or(|c| !no_line_start(c)) +/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph): Hard keeps +/// every character of the set off the line start and end, Soft lets 々, ー and small kana start a +/// line, None allows the break. +fn kinsoku_between(before: char, after: Option, kinsoku: Kinsoku) -> bool { + match kinsoku { + Kinsoku::None => true, + Kinsoku::Hard => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c)), + Kinsoku::Soft => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c) || soft_line_start(c)), + } } #[cfg(test)] @@ -1137,7 +1145,7 @@ mod wrapping_tests { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None); + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -1150,11 +1158,11 @@ mod wrapping_tests { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None); + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); - let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, false, Burasagari::None); + let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, false, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -1220,12 +1228,27 @@ fn japanese_letters_for_the_latin_space_are_kana_kanji_and_marks_not_hangul_or_p #[test] fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] { - assert!(!kinsoku_between(a, Some(b)), "{a}{b}"); + assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); } for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] { - assert!(kinsoku_between(a, Some(b)), "{a}{b}"); + assert!(kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); + } + assert!(kinsoku_between('。', None, Kinsoku::Hard)); +} + +/// Soft kinsoku lets 々, ー and small kana (JLREQ cl-09's 々, cl-10, cl-11) start a line and keeps +/// the rest of the set (half-width forms aren't in those classes); None allows any break. +#[cfg(test)] +#[test] +fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { + for (a, b) in [('ャ', 'ー'), ('カ', 'ッ'), ('時', '々'), ('ト', 'ㇰ'), ('か', 'ゃ')] { + assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}"); + assert!(kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}"); + } + for (a, b) in [('字', '。'), ('弧', '」'), ('「', 'か'), ('カ', 'ッ'), ('カ', 'ー'), ('字', 'ゝ')] { + assert!(!kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}"); + assert!(kinsoku_between(a, Some(b), Kinsoku::None), "{a}{b}"); } - assert!(kinsoku_between('。', None)); } /// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go. @@ -1255,26 +1278,26 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec { } /// Break candidates for the every-line composer. -fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec { +fn candidates(text: &str, g: &[SGlyph], hyphenate: bool, kinsoku: Kinsoku) -> Vec { let mut v = vec![]; let mut ws = 0; for (j, gl) in g.iter().enumerate() { let end_of_word = gl.is_space() || gl.break_after(); if end_of_word { if hyphenate && j > ws { - for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) { + for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) { v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); } } let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 }; - if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j) { + if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j, kinsoku) { v.push(Breakpoint { end: j + 1, hyphen: hy }); } ws = j + 1; } } if hyphenate && g.len() > ws { - for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) { + for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) { v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); } } @@ -1323,7 +1346,7 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt let width = |k: usize| widths.get(k).copied().unwrap_or(last); // Lines from `uniform_from` on are interchangeable (same width). let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1); - let cands = candidates(cx.text, sg, para.hyphenate); + let cands = candidates(cx.text, sg, para.hyphenate, para.kinsoku); let justify_last = para.justify == Justify::JustifyAll; let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from }; // Ragged: first look for breaks that leave at most one rag zone (a sixth of the width) on @@ -1406,7 +1429,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let width = x1 - x0 - ind_l - para.right_indent; let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { Some(&(e, h)) if e > i => (e, h), - _ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate, para.burasagari), + _ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate, para.burasagari, para.kinsoku), _ => (n, false), }; let m = Metrics::max(&sg[i..end]).unwrap_or(pm); diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index fd2b38278..503614a0e 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -133,6 +133,41 @@ pub(crate) fn no_line_start(c: char) -> bool { | '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ') } +/// Soft kinsoku: a character of [`no_line_start`] that may start a line all the same: 々, the +/// prolonged sound mark ー (JLREQ cl-10) and small kana (cl-11), as JLREQ's level 3 rules allow +/// (Appendix C.3, https://www.w3.org/TR/jlreq/#addendum_a3). +pub(crate) fn soft_line_start(c: char) -> bool { + matches!( + c, + '々' | 'ー' + | '\u{3041}' + | '\u{3043}' + | '\u{3045}' + | '\u{3047}' + | '\u{3049}' + | '\u{3063}' + | '\u{3083}' + | '\u{3085}' + | '\u{3087}' + | '\u{308E}' + | '\u{3095}' + | '\u{3096}' + | '\u{30A1}' + | '\u{30A3}' + | '\u{30A5}' + | '\u{30A7}' + | '\u{30A9}' + | '\u{30C3}' + | '\u{30E3}' + | '\u{30E5}' + | '\u{30E7}' + | '\u{30EE}' + | '\u{30F5}' + | '\u{30F6}' + | '\u{31F0}'..='\u{31FF}' + ) +} + /// Kinsoku: a character that can't end a line (opening brackets). pub(crate) fn no_line_end(c: char) -> bool { matches!( diff --git a/crates/text/src/tests_vertical.rs b/crates/text/src/tests_vertical.rs index f5a18f7f4..2d20ddf32 100644 --- a/crates/text/src/tests_vertical.rs +++ b/crates/text/src/tests_vertical.rs @@ -504,3 +504,32 @@ fn burasagari_hangs_a_comma_or_full_stop_outside_the_line() { let l = lay("一二三四五、六七", Burasagari::Standard, false); assert!((l.glyphs[5].origin.x - 100.0).abs() < 0.01, "{:?}", l.glyphs[5].origin); } + +/// Kinsoku Set: in a measure of two ems, あカッ breaks before ッ with Soft (a small kana may start a +/// line) and before カ with Hard (ッ goes with the character before it); None breaks as Soft here. +/// Horizontal and vertical. Needs a font with full-width Japanese (skipped without one). +#[test] +fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() { + use vectorcraft_doc::Kinsoku; + let lay = |text: &str, k: Kinsoku, vertical_type: bool| { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.kinsoku = k; + let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 40.0) } else { Rect::new(0.0, 0.0, 40.0, 200.0) }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + layout(FontDb::global(), &t) + }; + if (lay("雅", Kinsoku::Hard, false).glyphs[0].advance - 20.0).abs() > 2.0 { + return; // no font with full-width Japanese here + } + for vertical_type in [false, true] { + let lines = |k| lay("あカッ", k, vertical_type).glyphs.iter().map(|g| g.line).collect::>(); + assert_eq!(lines(Kinsoku::Hard), [0, 1, 1], "vertical {vertical_type}"); + assert_eq!(lines(Kinsoku::Soft), [0, 0, 1], "vertical {vertical_type}"); + assert_eq!(lines(Kinsoku::None), [0, 0, 1], "vertical {vertical_type}"); + // A full stop never starts a line, Soft or not. + let stop = |k| lay("あカ。", k, vertical_type).glyphs.iter().map(|g| g.line).collect::>(); + assert_eq!(stop(Kinsoku::Soft), [0, 1, 1], "vertical {vertical_type}"); + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 290ea0bf3..eee17dbd7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1474,6 +1474,9 @@ Modify Modificar Mojikumi Mojikumi Mojikumi Set Conjunto de mojikumi + Kinsoku Set Conjunto de kinsoku + Hard Fuerte + Soft Suave Monitor RGB RGB del monitor Mono Mono Monochrome images Imágenes monocromas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b249163c2..664ade4f5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1473,6 +1473,9 @@ Modify Modifier Mojikumi Mojikumi Mojikumi Set Jeu de mojikumi + Kinsoku Set Jeu de kinsoku + Hard Fort + Soft Souple Monitor RGB RVB du moniteur Mono Mono Monochrome images Images monochromes diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f833c0c53..fec6b2c25 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1474,6 +1474,9 @@ Modify Modifica Mojikumi Mojikumi Mojikumi Set Set mojikumi + Kinsoku Set Set kinsoku + Hard Forte + Soft Debole Monitor RGB RGB monitor Mono Mono Monochrome images Immagini monocromatiche diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1540e48d1..96945acf0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -37,6 +37,9 @@ Join Our Discord Discord に参加 Line-end Punctuation Half Width 行末約物半角 Mojikumi Set 文字組み + Kinsoku Set 禁則処理 + Hard 強い禁則 + Soft 弱い禁則 Latin 欧文 Monospaced 等幅 Roman Baseline 欧文ベースライン diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4929c9274..17342fcf8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1472,6 +1472,9 @@ Modify Изменить Mojikumi Mojikumi Mojikumi Set Набор mojikumi + Kinsoku Set Набор kinsoku + Hard Строгий + Soft Мягкий Monitor RGB RGB монитора Mono Моно Monochrome images Монохромные изображения diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 15939595c..2341e41dd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3266,6 +3266,9 @@ Right-to-Left Paragraph Direction 从右到左段落方向 Line-end Punctuation Half Width 行尾标点半角 Mojikumi Set 标点挤压集 + Kinsoku Set Kinsoku 集 + Hard 强 + Soft 弱 Saved Selection 1 已保存的选区 1 Saved Selection 2 已保存的选区 2 Saved Selection 3 已保存的选区 3 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d44527e25..f501937ca 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1474,6 +1474,9 @@ Modified: 修改時間: Modify 修改 Mojikumi Set 標點間距 + Kinsoku Set Kinsoku 集 + Hard 強 + Soft 弱 Monitor RGB 顯示器 RGB Mono 單色 Monochrome images 單色影像 diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 24c3e0d2b..101072bd2 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,5 +1,5 @@ //! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), indents, -//! space before/after, Hyphenate and Mojikumi Set (with the East Asian options); the panel menu +//! space before/after, Hyphenate, Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu //! picks the Single-line or Every-line Composer. use egui::{Ui, vec2}; @@ -141,6 +141,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { format(app, json!({"mojikumi": if i == 1 { "lineEndHalf" } else { "none" }})); } }); + // Which characters may not start or end a line. + ui.horizontal(|ui| { + use vectorcraft_doc::Kinsoku; + widgets::dim_label(ui, tl!("Kinsoku Set")); + let sets = [(tl!("None"), Kinsoku::None, "none"), (tl!("Hard"), Kinsoku::Hard, "hard"), (tl!("Soft"), Kinsoku::Soft, "soft")]; + let names = sets.map(|s| s.0); + let current = sets.iter().find(|s| s.1 == para.kinsoku).map_or(names[1], |s| s.0); + // Already translated: shown as they are. + if let Some(i) = widgets::dropdown_names(ui, "pa-kinsoku", current, &names, ui.available_width() - 4.0) + && let Some((_, _, key)) = sets.get(i) + { + format(app, json!({"kinsoku": key})); + } + }); } } @@ -198,7 +212,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { para_cmd(app, "text.setStyle", json!({"justify": "auto"})); format( app, - json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "composer": "everyLine"}), + json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine"}), ); } } diff --git a/docs/mcp.md b/docs/mcp.md index a54e2fff0..e9fc7d53e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2318,6 +2318,19 @@ Type and Convert To Point Type. Either keeps the text and its styles and is one {"name":"run_command","arguments":{"command":"type.convertToAreaType","params":{"ids":[42]}}} ``` +## Kinsoku Set + +`text.setFormat {kinsoku: "hard"|"soft"|"none"}` sets the Paragraph panel's Kinsoku Set of the selected type (or `ids`): +which Japanese characters may not start or end a line. **Hard** (the default; new type and documents from before the +setting) keeps closing brackets, commas, full stops, middle dots, iteration marks, the prolonged sound mark ー and small +kana off the line start, and opening brackets off the line end. **Soft** lets 々, ー and small kana start a line, as +JLREQ's level 3 line-breaking rules do (https://www.w3.org/TR/jlreq/#addendum_a3); the rest of the set stays. +**None** applies no kinsoku. `hard` isn't saved. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsoku":"soft"}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / From ffc02056135124865f8412c6d76173ee9359ad13 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 15:27:35 +0700 Subject: [PATCH 4/4] i18n: Flat Logo in Japanese and Chinese Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1540e48d1..a1c832d4f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1361,6 +1361,7 @@ Graphics: {adapter} グラフィックス: {adapter} Grays グレー Grayscale グレースケール + Flat Logo フラットロゴ Grayscale images グレースケールイメージ Green グリーン Grid Color & Opacity グリッドのカラーと不透明度 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 15939595c..d452b6ed1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1054,6 +1054,7 @@ Grays 灰度级数 Grays and Neutrals 灰色和中性色 Grayscale 灰度 + Flat Logo 扁平徽标 Grayscale images 灰度图像 Greek & Cyrillic 希腊文与西里尔文 Green 绿 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d44527e25..845b36864 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1080,6 +1080,7 @@ Grays 灰色 Grays and Neutrals 灰色與中性色 Grayscale 灰階 + Flat Logo 扁平標誌 Grayscale images 灰階影像 Greek & Cyrillic 希臘文與西里爾文 Green 綠