Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 f46527e0bd Fix #444: tool cursors are OS cursors, so the pointer no longer trails over the canvas
Over the canvas the window hid the system cursor and painted the tool's
cursor glyph into its own frame. That glyph reached the screen only after
the frame was rendered, queued (two frames by default) and composited, so
on Windows it visibly trailed the real pointer, on any GPU: the "sluggish
mouse" of the report. Frame times were not the cause (0.4 ms empty,
1.5 ms median on a 20 000-path document, and no repaints while idle).

- cursors: the glyphs are collected as shapes, tessellated by egui and
  rasterized into straight-alpha RGBA bitmaps (anti-aliased, hotspot on a
  pixel corner), cached per cursor and display scale so the same Arc is
  handed over every frame and egui-winit keeps its OS cursor.
- canvas: on Windows and Linux the tool cursor goes to the OS through
  ctx.set_cursor_image; the system moves it itself. macOS (bitmaps sized
  in points) and the web (no bitmap cursors in eframe) keep painting it.
- app: the wgpu surface queues one frame instead of two (LOW_LATENCY):
  the GPU only composites the CPU-rendered canvas, so everything drawn in
  the window follows the pointer a frame sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:41:22 +07:00
BFlat 1ce3a5c908 Merge pull request #448 from storytold/fix/issue-442
Fix #442: alike circular corners on rectangles that aren't square
2026-10-08 14:40:03 +07:00
BFlat 4254bf915e Merge pull request #441 from DvloperKing/es-errores
i18n: Spanish translations for error and status-bar messages
2026-10-08 14:39:46 +07:00
bflatasticandClaude Opus 5.5 44c7e64e1b Fix #442: alike circular corners on rectangles that aren't square
Live rectangles drew elliptical corners after an uneven scale in v0.4.0;
inconsistent on a rectangle that isn't square:

- doc: LiveShape::fold_scale moves an uneven scale left in `xf` (files
  saved before #291) into the size and radii; `transform` uses it, and
  `folded()`/`corner_style` give Live Corners document units.
- engine: object.setLiveShape folds that scale first, so setting a radius
  or kind (fields, dialog, widgets, MCP) gives circular corners on such
  shapes instead of keeping the ellipses.
- geom: max_corner_radius/fitted_corner_radius are the one limit every
  corner shares (half the shorter side).
- tools: corner widgets sit at the centres of the corners as drawn (a
  radius past the limit put them off the arc, further in along the long
  side), drag in document units on stretched shapes, and outline the
  edited corners in red at the limit, as the reference app does.
- svg: CSS export writes the drawn radii (CSS would draw a radius past the
  limit when its neighbours leave room).
- docs: mcp.md Live Corners, ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:37 +07:00
bflatasticandClaude Opus 5.5 7c7ce7f4fb i18n: Spanish messages say aplicación and match the UI's wording (review of #441)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:20 +07:00
Eliezer Hernandez f44155e098 i18n: Spanish translations for error and status-bar messages 2026-10-08 14:34:20 +07:00
BFlat 9533707893 Merge pull request #445 from storytold/fix/issue-443
Fix #443: installed fonts outside the font folders and fonts installed while the app runs reach the font menus
2026-10-08 14:34:12 +07:00
22 changed files with 1478 additions and 115 deletions

No files matched your search

+5 -5
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -99,7 +99,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
@@ -119,7 +119,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View.
@@ -134,7 +134,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.
- 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), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. 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. 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. 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.
- **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 and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. 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; 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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 and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish also translates 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 and Spanish 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; 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **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), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
@@ -155,7 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369).
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web).
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
+2 -1
View File
@@ -11,7 +11,8 @@
//! - Save / Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod locks;
+24 -1
View File
@@ -9,6 +9,7 @@ use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
@@ -49,6 +50,7 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
detect_language();
wasm_bindgen_futures::spawn_local(async move {
// Before the first frame, which looks for copies a crash left behind.
let locks = WebLocks::start(RECOVERY_PREFIX).await;
@@ -131,6 +133,27 @@ fn web_options() -> eframe::WebOptions {
options
}
/// Tell the UI the browser's languages (`?lang=es` in the address first, as `VECTORCRAFT_LOCALE`
/// does on the desktop) before the first frame, so the Automatic interface language follows the
/// browser; and show the loading text in that language.
fn detect_language() {
let mut tags: Vec<String> = query_param("lang").into_iter().collect();
if let Some(navigator) = web_sys::window().map(|w| w.navigator()) {
tags.extend(navigator.languages().iter().filter_map(|v| v.as_string()));
tags.extend(navigator.language());
}
i18n::set_system_locales(tags.as_slice());
if let Some(el) = element(LOADING_ID) {
el.set_text_content(Some(i18n::message(i18n::system_lang(), "Loading VectorCraft…").as_str()));
}
}
/// The value of `name=` in the address's query, if given.
fn query_param(name: &str) -> Option<String> {
let query = query();
query.trim_start_matches('?').split('&').find_map(|kv| kv.strip_prefix(name)?.strip_prefix('=').map(str::to_string)).filter(|v| !v.is_empty())
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -215,7 +238,7 @@ fn notice(lines: &[&str]) {
el.set_text_content(None);
for line in lines {
if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(line));
p.set_text_content(Some(i18n::message(i18n::system_lang(), line).as_str()));
// Best effort: the log has the message too.
el.append_child(&p).ok();
}
+3
View File
@@ -358,6 +358,9 @@ fn main() -> eframe::Result {
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
// composites it, so the window answers the pointer a frame sooner (#444).
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.power_preference = power;
}
+63 -19
View File
@@ -143,24 +143,11 @@ impl LiveShape {
/// scale in `xf` keep their exact geometry until they're next transformed).
pub fn transform(&mut self, a: Affine) -> bool {
match self {
LiveShape::Rectangle { w, h, radii, xf, .. } => {
let m = a * *xf;
LiveShape::Rectangle { xf, .. } => {
*xf = a * *xf;
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
if moves_only || !orthogonal || !finite {
*xf = m;
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
!moves_only && self.fold_scale()
}
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
*xf = a * *xf;
@@ -173,6 +160,35 @@ impl LiveShape {
}
}
}
/// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so
/// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the
/// document. False (and nothing changes) when there is no scale to move, when `xf` shears or
/// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an
/// uneven scale in `xf`: their corners are elliptical until this runs.
pub fn fold_scale(&mut self) -> bool {
let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false };
let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12;
if !orthogonal || !finite || unscaled {
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
}
/// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document
/// units (what Live Corners show and edit).
pub fn folded(&self) -> Self {
let mut s = self.clone();
s.fold_scale();
s
}
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
/// keep their size (Scale Corners off). False when nothing changed.
pub fn keep_corners(&mut self, k: f64) -> bool {
@@ -212,15 +228,16 @@ impl LiveShape {
pub fn corner_anchors(&self, corners: [bool; 4]) -> BTreeSet<AnchorRef> {
self.anchor_corners().into_iter().enumerate().filter(|(_, k)| corners.get(*k) == Some(&true)).map(|(ai, _)| (0, ai)).collect()
}
/// The radius and kind the `corners` of a rectangle share (each `None` when they differ).
/// The radius (in document units, see [`LiveShape::folded`]) and kind the `corners` of a
/// rectangle share (each `None` when they differ).
pub fn corner_style(&self, corners: [bool; 4]) -> (Option<f64>, Option<CornerKind>) {
let LiveShape::Rectangle { radii, kinds, .. } = self else { return (None, None) };
let LiveShape::Rectangle { radii, kinds, .. } = self.folded() else { return (None, None) };
fn shared<T: Copy>(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option<T> {
let mut it = v.into_iter().zip(corners).filter(|(_, on)| *on).map(|(x, _)| x);
let first = it.next()?;
it.all(|x| same(x, first)).then_some(first)
}
(shared(*radii, corners, |a, b| (a - b).abs() < 1e-9), shared(*kinds, corners, |a, b| a == b))
(shared(radii, corners, |a, b| (a - b).abs() < 1e-9), shared(kinds, corners, |a, b| a == b))
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
pub fn angle_deg(&self) -> f64 {
@@ -1395,6 +1412,33 @@ mod tests {
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
}
/// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live
/// Corners see and edit it in document units, with circular corners (#442).
#[test]
fn a_scale_left_in_the_transform_folds_into_the_size() {
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0);
let n = live_rect(50.0, 80.0, 20.0, legacy);
let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") };
let folded = live.folded();
assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone");
let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") };
// Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius.
assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}");
assert!(near_xf(*xf, Affine::translate((5.0, 5.0))));
let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap());
assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}");
assert_eq!(live.corner_style([true; 4]).0, Some(40.0), "the radius in document units");
// Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are.
let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0);
let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]);
for xf in [place, shear] {
let mut l = live_rect(100.0, 160.0, 20.0, xf);
let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") };
assert!(!live.fold_scale());
assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf));
}
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+3
View File
@@ -1038,6 +1038,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
// Radii are document lengths and corners circular, also on a rectangle from a file
// that kept an uneven scale in its transform (#442).
live.fold_scale();
let partial = sel.anchors.get_mut(id);
let picked = corners.unwrap_or_else(|| live.picked_corners(partial.as_deref()));
let (layout, selected) = (live.anchor_corners(), partial.as_deref().map(|a| live.corners_of(a)));
+45
View File
@@ -5,6 +5,7 @@ use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_geom::shapes::CornerKind;
use super::*;
@@ -105,3 +106,47 @@ fn a_drag_previews_one_corner_and_commits_one_step() {
assert_eq!(selected_anchors(&s, id), Some(anchors(&[2, 3])));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
}
/// How far each corner's cut reaches along x and y (top-left, top-right, bottom-right,
/// bottom-left) of a rectangle with four cut corners: equal for a circular arc.
fn spans(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") };
let a: Vec<_> = path.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(a.len(), 8, "four cut corners");
[(7, 0), (1, 2), (3, 4), (5, 6)].iter().map(|(i, j)| ((a[*j].x - a[*i].x).abs(), (a[*j].y - a[*i].y).abs())).collect()
}
fn assert_spans(s: &Session, id: NodeId, want: [f64; 4]) {
let got = spans(s, id);
let ok = got.iter().zip(want).all(|((x, y), r)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9);
assert!(ok, "corners span {got:?}, want circles of radii {want:?}");
}
/// #442: on a rectangle that isn't square, every corner is the same circle, also past the limit
/// and on a shape from a file that kept an uneven scale in its transform (before #291).
#[test]
fn corners_of_a_non_square_rectangle_are_alike_circles() {
let (mut s, id) = session();
run(&mut s, json!({"radius": 12}));
assert_spans(&s, id, [12.0; 4]);
// Past half the shorter side, every corner stops there.
run(&mut s, json!({"radius": 45}));
assert_spans(&s, id, [30.0; 4]);
// A 50 × 60 rectangle with 10 pt corners, saved stretched to 100 × 60: 20 × 10 ellipses.
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") };
let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0);
*live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
*path = live.to_path();
}
assert_eq!(spans(&s, id)[0], (20.0, 10.0));
// Rounding one corner makes every corner a circle: the others of the mean radius.
run(&mut s, json!({"corners": [1], "radius": 12}));
let mean = 10.0 * 2f64.sqrt();
assert_spans(&s, id, [mean, 12.0, mean, mean]);
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-9 && (b.x1 - 110.0).abs() < 1e-9 && (b.y1 - 70.0).abs() < 1e-9, "same bounds: {b:?}");
run(&mut s, json!({"radius": 8}));
assert_spans(&s, id, [8.0; 4]);
}
+31 -2
View File
@@ -47,6 +47,18 @@ impl CornerKind {
}
}
/// The largest corner radius a `w` × `h` rectangle draws: half its shorter side.
pub fn max_corner_radius(w: f64, h: f64) -> f64 {
w.abs().min(h.abs()) / 2.0
}
/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than
/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when
/// negative or not a number.
pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 {
radius.max(0.0).min(max_corner_radius(w, h))
}
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
@@ -63,7 +75,6 @@ pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec<usize> {
/// The anchors of [`rectangle_with_corners`], each with its corner.
fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usize, Anchor)> {
let r = r.abs();
let max = r.width().min(r.height()) / 2.0;
// Each corner, with the directions of the side arriving at it and of the side leaving it.
let corners = [
(Point::new(r.x0, r.y0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0)),
@@ -74,7 +85,7 @@ fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usiz
let mut out = Vec::with_capacity(8);
let mut top_left_start = None;
for (k, ((c, din, dout), (rad, kind))) in corners.into_iter().zip(radii.into_iter().zip(kinds)).enumerate() {
let rad = rad.max(0.0).min(max);
let rad = fitted_corner_radius(r.width(), r.height(), rad);
if rad <= 1e-9 {
out.push((k, Anchor::corner(c)));
continue;
@@ -312,6 +323,24 @@ mod tests {
assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]);
}
/// #442: on a rectangle that isn't square, every corner is a circular arc of the same radius,
/// and radii past the limit stop at half the shorter side, whichever side a corner is on.
#[test]
fn corners_of_a_non_square_rectangle_are_alike() {
let r = Rect::new(0.0, 0.0, 200.0, 80.0);
assert_eq!(max_corner_radius(-200.0, 80.0), 40.0);
assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]);
for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] {
for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] {
let a: Vec<Point> = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect();
for (i, j) in [(7, 0), (1, 2), (3, 4), (5, 6)] {
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
assert!((x - drawn).abs() < 1e-9 && (y - drawn).abs() < 1e-9, "radius {radius}: a corner spans {x} × {y}");
}
}
}
}
#[test]
fn polygon_first_vertex_up() {
let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0);
+9 -7
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind};
use vectorcraft_effects::RasterFx;
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
use crate::export::sanitize_id;
@@ -600,19 +600,21 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
let [_, b, c, _, _, _] = xf.as_coeffs();
b.abs() < 1e-9 && c.abs() < 1e-9
};
match live {
// Sizes and radii in document units.
match live.map(LiveShape::folded).as_ref() {
// CSS rounds corners only: an inverted round or chamfered corner has no border radius.
Some(LiveShape::Rectangle { radii, kinds, xf, .. })
Some(LiveShape::Rectangle { w, h, radii, kinds, xf })
if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) =>
{
let [a, _, _, d, _, _] = xf.as_coeffs();
let s = (a * d).abs().sqrt();
// The radii as drawn, none past half the shorter side (CSS draws one further when its
// neighbours leave room).
let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r));
if radii.iter().all(|r| *r <= 0.0) {
Some(Corners::Square)
} else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) {
Some(Corners::Round(vec![radii[0] * s]))
Some(Corners::Round(vec![radii[0]]))
} else {
Some(Corners::Round(radii.iter().map(|r| r.max(0.0) * s).collect()))
Some(Corners::Round(radii.to_vec()))
}
}
Some(LiveShape::Ellipse { pie, xf, .. }) if upright(xf) => {
+7
View File
@@ -84,6 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() {
kinds[1] = CornerKind::InvertedRound;
}
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
// A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS
// would draw it larger, its neighbours leaving room.
if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) {
*radii = [60.0, 8.0, 0.0, 4.0];
*kinds = Default::default();
}
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px"));
}
#[test]
+100 -15
View File
@@ -7,8 +7,8 @@
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2};
use crate::{Action, Overlay, PointerEvent, ToolContext};
@@ -47,18 +47,20 @@ impl CornerWidgets {
return None;
}
let Some(NodeKind::Path { live: Some(live), .. }) = doc.node(id).map(|n| &n.kind) else { return None };
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live else { return None };
let (w, h, radii, kinds, xf) = (*w, *h, *radii, *kinds, *xf);
// In document units, as `object.setLiveShape` edits them.
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live.folded() else { return None };
// Screen pixels per shape unit along each side (the shape may be scaled or skewed).
let c = xf.as_coeffs();
let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom);
if xf.determinant().abs() < 1e-12 || (w * px).min(h * py) < MIN_SIDE_PX {
return None;
}
// Each widget sits on its corner's diagonal, at the centre of the arc as drawn.
let points = std::array::from_fn(|k| {
let ((fx, fy), (sx, sy)) = CORNERS[k];
let ix = radii[k].max(MIN_INSET_PX / px).min(w / 2.0);
let iy = radii[k].max(MIN_INSET_PX / py).min(h / 2.0);
let r = shapes::fitted_corner_radius(w, h, radii[k]);
let ix = r.max(MIN_INSET_PX / px).min(w / 2.0);
let iy = r.max(MIN_INSET_PX / py).min(h / 2.0);
xf * Point::new(fx * w + sx * ix, fy * h + sy * iy)
});
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
@@ -138,6 +140,8 @@ pub struct CornerDrag {
began: bool,
alt: bool,
radius: f64,
/// The pointer went past fully round: the corners are at their largest radius.
at_limit: bool,
at: Point,
}
@@ -146,18 +150,41 @@ impl CornerDrag {
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
let (widgets, p) = (CornerWidgets::for_tool(cx)?, ev.pos);
let corner = widgets.hit(p, cx.tol(5.0))?;
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at: p })
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at_limit: false, at: p })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's inward diagonal (in the shape's own units), from square to fully round.
fn radius_at(&self, p: Point) -> f64 {
/// corner's inward diagonal (in the shape's own units), from square to fully round; and
/// whether the travel reached fully round (the limit every corner shares).
fn radius_at(&self, p: Point) -> (f64, bool) {
let w = &self.widgets;
let inv = w.xf.inverse();
let d = inv * p - inv * self.start;
let (_, (sx, sy)) = CORNERS[self.corner];
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
let max = shapes::max_corner_radius(w.w, w.h);
let r = shapes::fitted_corner_radius(w.w, w.h, w.radii[self.corner]) + (d.x * sx + d.y * sy) / 2.0;
(r.min(max).max(0.0), max > 0.0 && r >= max)
}
/// The edited corners' cuts at the dragged radius, in document coordinates.
fn edited_corners(&self) -> BezPath {
let w = &self.widgets;
let rect = Rect::new(0.0, 0.0, w.w, w.h);
let radii = std::array::from_fn(|k| if w.shown[k] { self.radius } else { w.radii[k] });
let corner_of = shapes::rectangle_anchor_corners(rect, radii);
let path = shapes::rectangle_with_corners(rect, radii, w.kinds).transformed(w.xf);
let mut out = BezPath::new();
let Some(sp) = path.subpaths.first() else { return out };
// A corner's cut is the segment between its two anchors (the last one, for the top-left).
for i in 0..sp.segment_count() {
let (a, b) = (corner_of.get(i), corner_of.get(i + 1).or(corner_of.first()));
if a == b && a.is_some_and(|k| w.shown.get(*k) == Some(&true)) {
let c = sp.segment(i);
out.move_to(c.p0);
out.curve_to(c.p1, c.p2, c.p3);
}
}
out
}
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
@@ -169,7 +196,7 @@ impl CornerDrag {
self.began = true;
out.push(Action::Begin("Corner Radius".into()));
}
self.radius = self.radius_at(p);
(self.radius, self.at_limit) = self.radius_at(p);
self.at = p;
out.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius))));
out
@@ -186,12 +213,16 @@ impl CornerDrag {
}
}
/// The radius readout next to the pointer.
/// The radius readout next to the pointer; at the largest radius, the edited corners in red.
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if !self.began {
return vec![];
}
vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }]
let mut out = vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }];
if self.at_limit {
out.push(Overlay::Path { path: self.edited_corners(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
}
out
}
}
@@ -204,10 +235,15 @@ mod tests {
/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`.
fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) {
live_shape(100.0, 100.0, r, xf)
}
/// A live `w` × `h` rectangle with corner radius `r`.
fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [r; 4], kinds: Default::default(), xf };
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
@@ -264,6 +300,55 @@ mod tests {
}
}
/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also
/// with a radius past half the shorter side; a drag past fully round stops every corner there
/// and outlines them in red.
#[test]
fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() {
let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0)));
let s = selected(id);
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)];
assert_eq!(w.points, centres);
let p = paint();
let cx = cx(&d, &s, &p);
let red = |t: &dyn crate::Tool| {
t.overlays(&cx).into_iter().find_map(|o| match o {
Overlay::Path { path, color, .. } if color == crate::builder::HIGHLIGHT_RED => Some(path),
_ => None,
})
};
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 140.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 200.0));
assert_eq!(radius(&a[1]), 40.0);
// The four arcs, each a quarter circle of radius 40 starting on a side.
let arcs = red(t.as_ref()).expect("the limit shows");
let starts: Vec<_> =
arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]);
// Back below the limit, the outline goes.
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0);
assert!(red(t.as_ref()).is_none());
}
/// #442: a rectangle saved stretched (an uneven scale in its transform, before #291) shows its
/// widgets and drags its radius in document units, as `object.setLiveShape` sets it.
#[test]
fn a_stretched_rectangle_drags_its_radius_in_document_units() {
// 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20.
let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0));
let s = selected(id);
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
assert_eq!(w.points[0], Point::new(120.0, 120.0));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0, 120.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 110.0));
assert!((radius(&a[1]) - 10.0).abs() < 1e-9, "{a:?}");
}
#[test]
fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
// Rotated 90°: the shape's top-left corner is at document (200, 100).
+7
View File
@@ -388,6 +388,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
match ui.input(|i| i.pointer.hover_pos()) {
// An OS cursor: the system moves it at once, where a painted one trails the pointer
// (#444). The system cursor stands in for cursors without a glyph.
Some(_) if custom && crate::cursors::OS_CURSORS => {
let ppp = ui.ctx().native_pixels_per_point().unwrap_or_else(|| ui.ctx().pixels_per_point());
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
cursor_icon(c)
}
Some(hp) if custom && crate::cursors::paint(&painter, c, hp) => egui::CursorIcon::None,
_ => cursor_icon(c),
}
+4 -2
View File
@@ -422,11 +422,13 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
// Background saves and exports in progress, else the last message.
if let Some(job) = app.background.jobs.first() {
let more = app.background.jobs.len() - 1;
let text = if more > 0 { format!("{}… (+{more})", job.label) } else { format!("{}…", job.label) };
let label = crate::i18n::msg(&job.label);
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
} else if !app.ui.status.is_empty() {
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
// The message stays English in `ui.status` (agents and tests read it).
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
}
});
});
+339 -53
View File
@@ -1,21 +1,44 @@
//! Tool cursors drawn as vector glyphs (egui only offers system cursors). Black shapes with a white
//! halo, hotspot at `p`, Illustrator's visual grammar: solid arrow (Selection), hollow arrow (Direct
//! Selection), pen nib with state badges, crosshair for drawing tools, curved arrows for rotate.
//!
//! The desktop app shows them as OS cursors ([`Images`]): the system moves those itself, at once,
//! while a cursor painted into the window follows the pointer a few frames late (#444).
use egui::{Color32, Painter, Pos2, Shape, Stroke, pos2, vec2};
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
use egui::{Color32, CustomCursorImage, Painter, Pos2, Shape, Stroke, Vec2, pos2, vec2};
use vectorcraft_tools::Cursor;
const INK: Color32 = Color32::BLACK;
const HALO: Color32 = Color32::WHITE;
fn poly(p: &Painter, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
/// The shapes of one cursor glyph.
#[derive(Default)]
struct Ink(Vec<Shape>);
impl Ink {
fn add(&mut self, s: Shape) {
self.0.push(s);
}
fn line_segment(&mut self, pts: [Pos2; 2], stroke: Stroke) {
self.add(Shape::line_segment(pts, stroke));
}
fn circle_stroke(&mut self, c: Pos2, r: f32, stroke: Stroke) {
self.add(Shape::circle_stroke(c, r, stroke));
}
fn circle_filled(&mut self, c: Pos2, r: f32, fill: Color32) {
self.add(Shape::circle_filled(c, r, fill));
}
}
fn poly(p: &mut Ink, pts: Vec<Pos2>, fill: Color32, stroke: Color32) {
// Halo first (thicker white outline), then the glyph.
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
p.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
p.add(Shape::closed_line(pts, Stroke::new(1.0, stroke)));
}
fn line(p: &Painter, a: Pos2, b: Pos2) {
fn line(p: &mut Ink, a: Pos2, b: Pos2) {
p.line_segment([a, b], Stroke::new(3.0, HALO));
p.line_segment([a, b], Stroke::new(1.2, INK));
}
@@ -25,7 +48,7 @@ fn arrow_points(o: Pos2) -> Vec<Pos2> {
[(0.0, 0.0), (0.0, 15.0), (3.8, 11.4), (6.4, 17.0), (8.6, 16.0), (6.1, 10.6), (11.0, 10.6)].iter().map(|(x, y)| o + vec2(*x, *y)).collect()
}
fn arrow(p: &Painter, o: Pos2, hollow: bool) {
fn arrow(p: &mut Ink, o: Pos2, hollow: bool) {
let pts = arrow_points(o);
// The arrow is concave: draw as a filled mesh of two convex parts, then outline.
p.add(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO)));
@@ -35,7 +58,7 @@ fn arrow(p: &Painter, o: Pos2, hollow: bool) {
p.add(Shape::closed_line(pts, Stroke::new(1.0, INK)));
}
fn crosshair(p: &Painter, o: Pos2) {
fn crosshair(p: &mut Ink, o: Pos2) {
for (a, b) in
[(vec2(-9.0, 0.0), vec2(-2.0, 0.0)), (vec2(2.0, 0.0), vec2(9.0, 0.0)), (vec2(0.0, -9.0), vec2(0.0, -2.0)), (vec2(0.0, 2.0), vec2(0.0, 9.0))]
{
@@ -43,7 +66,7 @@ fn crosshair(p: &Painter, o: Pos2) {
}
}
fn pen(p: &Painter, o: Pos2, badge: &str) {
fn pen(p: &mut Ink, o: Pos2, badge: &str) {
// Nib pointing to the top-left hotspot.
let pts = vec![o, o + vec2(4.0, 12.0), o + vec2(8.0, 16.0), o + vec2(16.0, 8.0), o + vec2(12.0, 4.0)];
poly(p, pts, INK, INK);
@@ -74,7 +97,7 @@ fn pen(p: &Painter, o: Pos2, badge: &str) {
}
}
fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
let d = dir.normalized() * 8.0;
let n = vec2(-d.y, d.x) * 0.45;
line(p, o - d, o + d);
@@ -83,7 +106,7 @@ fn double_arrow(p: &Painter, o: Pos2, dir: egui::Vec2) {
}
}
fn rotate(p: &Painter, o: Pos2) {
fn rotate(p: &mut Ink, o: Pos2) {
let pts: Vec<Pos2> = (0..=10)
.map(|i| {
let a = std::f32::consts::PI * (0.15 + 0.7 * i as f32 / 10.0);
@@ -98,7 +121,7 @@ fn rotate(p: &Painter, o: Pos2) {
}
/// Live Corners: the hollow arrow with a rounded-corner badge.
fn corner_radius(p: &Painter, o: Pos2) {
fn corner_radius(p: &mut Ink, o: Pos2) {
arrow(p, o, true);
let b = o + vec2(12.0, 13.0);
let mut pts = vec![b + vec2(0.0, 10.0)];
@@ -113,7 +136,7 @@ fn corner_radius(p: &Painter, o: Pos2) {
/// Over a bracket of type on a path: the arrow with a bracket (a stem standing on a baseline,
/// with a foot) below right.
fn path_bracket(p: &Painter, o: Pos2) {
fn path_bracket(p: &mut Ink, o: Pos2) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
let stem = [b + vec2(3.0, 0.0), b + vec2(3.0, 10.0)];
@@ -128,7 +151,7 @@ fn path_bracket(p: &Painter, o: Pos2) {
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
/// badge.
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
fn stop_badge(p: &mut Ink, o: Pos2, add: bool) {
arrow(p, o, false);
let b = o + vec2(13.0, 13.0);
line(p, b, b + vec2(6.0, 0.0));
@@ -138,14 +161,14 @@ fn stop_badge(p: &Painter, o: Pos2, add: bool) {
}
/// A slice badge: a small rectangle cut by a line, at `b` (its top left).
fn slice_badge(p: &Painter, b: Pos2) {
fn slice_badge(p: &mut Ink, b: Pos2) {
let pts = vec![b, b + vec2(8.0, 0.0), b + vec2(8.0, 6.0), b + vec2(0.0, 6.0)];
poly(p, pts, HALO, INK);
line(p, b + vec2(4.0, 0.0), b + vec2(4.0, 6.0));
}
/// The Slice tool: a crosshair with a blade below right of the hotspot.
fn slice(p: &Painter, o: Pos2) {
fn slice(p: &mut Ink, o: Pos2) {
crosshair(p, o);
let b = o + vec2(7.0, 7.0);
poly(p, vec![b, b + vec2(9.0, 4.0), b + vec2(10.0, 7.0), b + vec2(3.0, 6.0)], HALO, INK);
@@ -155,7 +178,7 @@ fn slice(p: &Painter, o: Pos2) {
/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point),
/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point
/// (a drag moves or widens it).
fn width(p: &Painter, o: Pos2, badge: &str) {
fn width(p: &mut Ink, o: Pos2, badge: &str) {
arrow(p, o, true);
let b = o + vec2(11.0, 18.0);
let top: Vec<Pos2> = (0..=8)
@@ -179,7 +202,7 @@ fn width(p: &Painter, o: Pos2, badge: &str) {
}
}
fn ibeam(p: &Painter, o: Pos2) {
fn ibeam(p: &mut Ink, o: Pos2) {
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
line(p, o + vec2(-3.0, 8.0), o + vec2(3.0, 8.0));
@@ -188,7 +211,7 @@ fn ibeam(p: &Painter, o: Pos2) {
/// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art,
/// filled over an object; over an anchor point a ringed dot (the blend starts there).
fn blend(p: &Painter, o: Pos2, badge: Cursor) {
fn blend(p: &mut Ink, o: Pos2, badge: Cursor) {
crosshair(p, o);
let b = o + vec2(9.0, 9.0);
if badge == Cursor::BlendAnchor {
@@ -203,7 +226,7 @@ fn blend(p: &Painter, o: Pos2, badge: Cursor) {
/// The Shape Builder: a crosshair with a plus badge below right of the hotspot (merge mode), or a
/// minus (erase mode).
fn shape_builder(p: &Painter, o: Pos2, erase: bool) {
fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) {
crosshair(p, o);
let c = o + vec2(12.0, 12.0);
line(p, c - vec2(3.5, 0.0), c + vec2(3.5, 0.0));
@@ -212,53 +235,316 @@ fn shape_builder(p: &Painter, o: Pos2, erase: bool) {
}
}
/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should
/// stay system cursors (hand, zoom, busy states).
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
/// (hand, zoom, busy states).
fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
let ink = &mut Ink::default();
match c {
Cursor::Arrow => arrow(painter, p, false),
Cursor::ArrowHollow => arrow(painter, p, true),
Cursor::Arrow => arrow(ink, p, false),
Cursor::ArrowHollow => arrow(ink, p, true),
Cursor::Move => {
arrow(painter, p, false);
double_arrow(painter, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
arrow(ink, p, false);
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
}
Cursor::Crosshair | Cursor::Eyedropper => crosshair(painter, p),
Cursor::ResizeH => double_arrow(painter, p, vec2(1.0, 0.0)),
Cursor::ResizeV => double_arrow(painter, p, vec2(0.0, 1.0)),
Cursor::ResizeNwSe => double_arrow(painter, p, vec2(1.0, 1.0)),
Cursor::ResizeNeSw => double_arrow(painter, p, vec2(1.0, -1.0)),
Cursor::Rotate => rotate(painter, p),
Cursor::CornerRadius => corner_radius(painter, p),
Cursor::Pen => pen(painter, p, "*"),
Cursor::PenAdd => pen(painter, p, "+"),
Cursor::PenDelete => pen(painter, p, "-"),
Cursor::PenClose => pen(painter, p, "o"),
Cursor::PenContinue => pen(painter, p, "/"),
Cursor::PenConvert => pen(painter, p, "^"),
Cursor::Text => ibeam(painter, p),
Cursor::AddStop => stop_badge(painter, p, true),
Cursor::RemoveStop => stop_badge(painter, p, false),
Cursor::Slice => slice(painter, p),
Cursor::Crosshair | Cursor::Eyedropper => crosshair(ink, p),
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
Cursor::Rotate => rotate(ink, p),
Cursor::CornerRadius => corner_radius(ink, p),
Cursor::Pen => pen(ink, p, "*"),
Cursor::PenAdd => pen(ink, p, "+"),
Cursor::PenDelete => pen(ink, p, "-"),
Cursor::PenClose => pen(ink, p, "o"),
Cursor::PenContinue => pen(ink, p, "/"),
Cursor::PenConvert => pen(ink, p, "^"),
Cursor::Text => ibeam(ink, p),
Cursor::AddStop => stop_badge(ink, p, true),
Cursor::RemoveStop => stop_badge(ink, p, false),
Cursor::Slice => slice(ink, p),
Cursor::SliceSelect => {
arrow(painter, p, false);
slice_badge(painter, p + vec2(11.0, 14.0));
arrow(ink, p, false);
slice_badge(ink, p + vec2(11.0, 14.0));
}
Cursor::Width => width(ink, p, ""),
Cursor::WidthAdd => width(ink, p, "+"),
Cursor::WidthPoint => width(ink, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(ink, p, c),
Cursor::PathBracket => path_bracket(ink, p),
Cursor::ShapeBuilder => shape_builder(ink, p, false),
Cursor::ShapeBuilderErase => shape_builder(ink, p, true),
_ => return None,
}
Some(std::mem::take(&mut ink.0))
}
/// Paint cursor `c` at `p` on the given (foreground) painter, where the window can't show it as an
/// OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay system cursors.
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
glyph(c, p).map(|shapes| painter.extend(shapes)).is_some()
}
/// Does the window show the glyphs as OS cursors ([`Images`])? Not on macOS, which sizes a cursor
/// bitmap in points (a Retina one would show twice as big, or blurred), nor on the web (eframe has no
/// bitmap cursors there): those paint them into the window ([`paint`]).
pub const OS_CURSORS: bool = !cfg!(any(target_os = "macos", target_arch = "wasm32"));
/// The cursor bitmaps handed to the OS, by (cursor, pixels per point): each glyph is rasterized
/// once, and handing the same `Arc` every frame keeps the window's OS cursor instead of making a
/// new one.
#[derive(Default)]
pub struct Images(Vec<(Cursor, f32, CustomCursorImage)>);
impl Images {
/// Cursor `c` for a display of `ppp` physical pixels per point; `None` for cursors that stay
/// system cursors.
pub fn get(&mut self, c: Cursor, ppp: f32) -> Option<CustomCursorImage> {
if let Some((.., image)) = self.0.iter().find(|(k, s, _)| *k == c && *s == ppp) {
return Some(image.clone());
}
let image = rasterize(c, ppp)?;
// A few dozen glyphs at the scales of the displays the window visited.
if self.0.len() >= 128 {
self.0.clear();
}
self.0.push((c, ppp, image.clone()));
Some(image)
}
}
/// The largest side of a cursor bitmap, in pixels (the glyphs span under 50 points).
const MAX_SIDE: f32 = 256.0;
/// Cursor `c` as a straight-alpha RGBA bitmap at `ppp` pixels per point, tessellated (anti-aliased)
/// as egui would paint it, the hotspot on a pixel corner.
fn rasterize(c: Cursor, ppp: f32) -> Option<CustomCursorImage> {
if !(ppp.is_finite() && ppp > 0.0) {
return None;
}
let shapes = glyph(c, Pos2::ZERO)?;
let mut tessellator = Tessellator::new(ppp, TessellationOptions::default(), [1, 1], vec![]);
let mut mesh = Mesh::default();
for shape in shapes {
tessellator.tessellate_shape(shape, &mut mesh);
}
let bounds = mesh.calc_bounds();
// The hotspot must lie inside the bitmap.
let min = (bounds.min.to_vec2() * ppp).floor().min(Vec2::ZERO);
let max = (bounds.max.to_vec2() * ppp).ceil().max(Vec2::splat(1.0));
let size = max - min;
if !(size.x <= MAX_SIDE && size.y <= MAX_SIDE) {
return None;
}
let (w, h) = (size.x as usize, size.y as usize);
let mut px = vec![[0.0; 4]; w * h];
let corner = |v: &Vertex| (v.pos.to_vec2() * ppp - min, v.color);
for tri in mesh.indices.chunks_exact(3) {
let &[i, j, k] = tri else { continue };
if let (Some(a), Some(b), Some(c)) = (mesh.vertices.get(i as usize), mesh.vertices.get(j as usize), mesh.vertices.get(k as usize)) {
fill_triangle(&mut px, w, [corner(a), corner(b), corner(c)]);
}
}
let rgba: Vec<u8> = px
.iter()
.flat_map(|&[r, g, b, a]| {
let k = if a > 0.0 { 1.0 / a } else { 0.0 };
[r * k, g * k, b * k, a].map(|x| (x * 255.0).round().clamp(0.0, 255.0) as u8)
})
.collect();
Some(CustomCursorImage { rgba: rgba.into(), size: [w as u16, h as u16], hotspot: [-min.x as u16, -min.y as u16] })
}
/// Blend triangle `t` (pixel positions and premultiplied colours, interpolated across it) over `px`
/// (premultiplied RGBA, `w` pixels a row) at the pixel centres it covers, as the GPU blends egui's
/// meshes. A centre on an edge goes to one of the two triangles sharing it, so seams aren't doubled.
fn fill_triangle(px: &mut [[f32; 4]], w: usize, t: [(Vec2, Color32); 3]) {
let edge = |a: Vec2, b: Vec2, p: Vec2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
let [a, mut b, mut c] = t;
let mut area = edge(a.0, b.0, c.0);
if area < 0.0 {
std::mem::swap(&mut b, &mut c);
area = -area;
}
if area.is_nan() || area <= 1e-6 || w == 0 {
return;
}
// Of the two triangles sharing an edge (walking it in opposite directions), one owns it.
let owns = |u: Vec2, v: Vec2| v.y > u.y || (v.y == u.y && v.x > u.x);
let h = px.len() / w;
let lo = a.0.min(b.0).min(c.0).max(Vec2::ZERO);
let hi = a.0.max(b.0).max(c.0).min(vec2(w as f32, h as f32));
for y in lo.y.floor() as usize..hi.y.ceil() as usize {
for x in lo.x.floor() as usize..hi.x.ceil() as usize {
let p = vec2(x as f32 + 0.5, y as f32 + 0.5);
let weights = [(b.0, c.0), (c.0, a.0), (a.0, b.0)].map(|(u, v)| (edge(u, v, p), owns(u, v)));
if !weights.iter().all(|&(e, own)| e > 0.0 || (e == 0.0 && own)) {
continue;
}
let Some(dst) = px.get_mut(y * w + x) else { continue };
let [wa, wb, wc] = weights.map(|(e, _)| e / area);
let src: [f32; 4] = std::array::from_fn(|i| {
let channel = |col: Color32| f32::from(col.to_array()[i]);
(wa * channel(a.1) + wb * channel(b.1) + wc * channel(c.1)) / 255.0
});
let keep = 1.0 - src[3];
*dst = std::array::from_fn(|i| src[i] + dst[i] * keep);
}
Cursor::Width => width(painter, p, ""),
Cursor::WidthAdd => width(painter, p, "+"),
Cursor::WidthPoint => width(painter, p, "point"),
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
Cursor::PathBracket => path_bracket(painter, p),
Cursor::ShapeBuilder => shape_builder(painter, p, false),
Cursor::ShapeBuilderErase => shape_builder(painter, p, true),
_ => return false,
}
let _ = pos2;
true
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
/// Every cursor with a glyph.
const GLYPHS: [Cursor; 31] = [
Cursor::Arrow,
Cursor::ArrowHollow,
Cursor::Move,
Cursor::Crosshair,
Cursor::ResizeH,
Cursor::ResizeV,
Cursor::ResizeNwSe,
Cursor::ResizeNeSw,
Cursor::Rotate,
Cursor::CornerRadius,
Cursor::Pen,
Cursor::PenAdd,
Cursor::PenDelete,
Cursor::PenClose,
Cursor::PenContinue,
Cursor::PenConvert,
Cursor::Text,
Cursor::Eyedropper,
Cursor::AddStop,
Cursor::RemoveStop,
Cursor::Slice,
Cursor::SliceSelect,
Cursor::Width,
Cursor::WidthAdd,
Cursor::WidthPoint,
Cursor::Blend,
Cursor::BlendObject,
Cursor::BlendAnchor,
Cursor::PathBracket,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
];
/// The straight RGBA of pixel (x, y).
fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] {
let i = (y * img.size[0] as usize + x) * 4;
[img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]]
}
#[test]
fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() {
for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] {
for c in GLYPHS {
let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}"));
let [w, h] = img.size.map(usize::from);
assert_eq!(img.rgba.len(), w * h * 4, "{c:?}");
assert!(img.hotspot[0] < img.size[0] && img.hotspot[1] < img.size[1], "{c:?} at {ppp}: {img:?}");
assert!(w as f32 <= 50.0 * ppp && h as f32 <= 50.0 * ppp, "{c:?} at {ppp}: {img:?}");
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255), "{c:?}: nothing opaque");
}
}
for c in [Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, Cursor::ZoomOut, Cursor::NotAllowed] {
assert!(rasterize(c, 1.0).is_none(), "{c:?} stays a system cursor");
}
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
}
}
#[test]
fn the_arrow_bitmap_is_black_in_a_white_halo_with_the_tip_on_the_hotspot() {
let img = rasterize(Cursor::Arrow, 1.0).unwrap();
let [hx, hy] = img.hotspot.map(usize::from);
// The tip covers the hotspot's pixel; the body is solid black, the outline's halo white.
assert!(pixel(&img, hx, hy)[3] > 0, "{:?}", pixel(&img, hx, hy));
let body = pixel(&img, hx + 2, hy + 8);
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo");
// Nothing left of or above the tip but the halo.
assert!((0..img.size[1] as usize).all(|y| pixel(&img, 0, y)[..3].iter().all(|&c| c > 100) || pixel(&img, 0, y)[3] < 128));
// At twice the pixels per point, twice the pixels.
let big = rasterize(Cursor::Arrow, 2.0).unwrap();
for i in 0..2 {
let ratio = f32::from(big.size[i]) / f32::from(img.size[i]);
assert!((1.8..=2.2).contains(&ratio), "{:?} vs {:?}", big.size, img.size);
}
let [bx, by] = big.hotspot.map(usize::from);
let body = pixel(&big, bx + 4, by + 16);
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
}
#[test]
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
let mut images = Images::default();
let a = images.get(Cursor::Pen, 1.5).unwrap();
let b = images.get(Cursor::Pen, 1.5).unwrap();
assert!(Arc::ptr_eq(&a.rgba, &b.rgba));
assert!(!Arc::ptr_eq(&a.rgba, &images.get(Cursor::Pen, 2.0).unwrap().rgba));
assert!(images.get(Cursor::Hand, 1.5).is_none());
assert!(images.get(Cursor::Arrow, f32::NAN).is_none());
assert_eq!(images.0.len(), 2);
}
/// One headless 800×600 frame of the whole window, a tenth of a second after the last.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> egui::FullOutput {
let time = Some(ctx.input(|i| i.time) + 0.1);
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time, events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
}
#[test]
fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx, vec![]);
}
let canvas = app.canvas_rect.unwrap();
let over = canvas.center();
let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
if OS_CURSORS {
let img = out.platform_output.cursor_image.as_ref().expect("the Selection tool's arrow as an OS cursor");
assert_eq!(img, &app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap());
// Where the OS can't show the bitmap, its system cursor.
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default);
} else {
assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None);
}
// The same bitmap while the pointer moves (the OS keeps its cursor), and an idle window
// once it rests: no frame is asked for.
let next = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over + vec2(5.0, 3.0))]);
if OS_CURSORS {
assert!(Arc::ptr_eq(&next.platform_output.cursor_image.unwrap().rgba, &out.platform_output.cursor_image.unwrap().rgba));
}
let mut idle = frame(&mut app, &ctx, vec![]);
for _ in 0..3 {
idle = frame(&mut app, &ctx, vec![]);
}
let delay = idle.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay).unwrap();
assert!(delay > std::time::Duration::from_millis(100), "{delay:?} {:?}", ctx.repaint_causes());
// Off the canvas, the panels' cursors.
let off = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(canvas.left() - 20.0, canvas.center().y))]);
assert!(off.platform_output.cursor_image.is_none());
}
#[test]
fn arrow_hotspot_is_tip() {
+108
View File
@@ -13,6 +13,92 @@ pub struct Catalog {
ids: HashMap<String, String>,
/// plural messages: `one|other` → forms
plurals: HashMap<String, Vec<String>>,
/// status and error messages without placeholders (`@msg`): English → translation
messages: HashMap<String, String>,
/// status and error messages with `{name}` placeholders (`@msg`), most specific first
templates: Vec<Template>,
}
/// A piece of a message template: literal text or a `{name}` placeholder.
#[derive(Debug)]
enum Piece {
Text(String),
Hole(String),
}
/// A message template (`Couldn't open {name}: {e}`) and its translation.
#[derive(Debug)]
struct Template {
pieces: Vec<Piece>,
/// Bytes of literal text: a template with more of it is the more specific match.
literal: usize,
translation: String,
}
/// Messages longer than this are shown as they are (template matching backtracks).
const MAX_MESSAGE_LEN: usize = 2000;
impl Template {
/// `None` for a template that can't be matched reliably: no literal text, or two placeholders
/// in a row (where one ends and the next begins is unknowable).
fn parse(source: &str, translation: String) -> Option<Template> {
let mut pieces = Vec::new();
let mut rest = source;
while let Some(a) = rest.find('{') {
let after = rest.get(a + 1..)?;
let b = after.find('}')?;
if a > 0 {
pieces.push(Piece::Text(rest.get(..a)?.to_string()));
} else if matches!(pieces.last(), Some(Piece::Hole(_))) {
return None;
}
pieces.push(Piece::Hole(after.get(..b)?.to_string()));
rest = after.get(b + 1..)?;
}
if !rest.is_empty() {
pieces.push(Piece::Text(rest.to_string()));
}
let literal = pieces.iter().map(|p| if let Piece::Text(t) = p { t.len() } else { 0 }).sum();
(literal > 0).then_some(Template { pieces, literal, translation })
}
/// The placeholder values if `s` is an instance of this template.
fn captures<'a>(&self, s: &'a str) -> Option<Vec<(&str, &'a str)>> {
let mut out = Vec::new();
match_pieces(&self.pieces, s, &mut out).then_some(out)
}
}
/// Match `s` against `pieces`, a placeholder taking the shortest non-empty text that lets the
/// rest match (the last one takes what remains).
fn match_pieces<'p, 'a>(pieces: &'p [Piece], s: &'a str, out: &mut Vec<(&'p str, &'a str)>) -> bool {
let Some((first, rest)) = pieces.split_first() else { return s.is_empty() };
match first {
Piece::Text(t) => s.strip_prefix(t.as_str()).is_some_and(|tail| match_pieces(rest, tail, out)),
Piece::Hole(name) => {
let Some((next, after)) = rest.split_first() else {
if s.is_empty() {
return false;
}
out.push((name.as_str(), s));
return true;
};
let Piece::Text(next) = next else { return false };
// Every place the next literal occurs, nearest first (the hole is never empty).
let mut from = s.chars().next().map_or(1, char::len_utf8);
while let Some(at) = s.get(from..).and_then(|tail| tail.find(next.as_str())).map(|i| i + from) {
let (Some(value), Some(tail)) = (s.get(..at), s.get(at + next.len()..)) else { return false };
let mark = out.len();
out.push((name.as_str(), value));
if match_pieces(after, tail, out) {
return true;
}
out.truncate(mark);
from = at + next.chars().next().map_or(1, char::len_utf8);
}
false
}
}
}
/// A catalog entry as read from the file: (context, source, translation).
@@ -74,11 +160,20 @@ impl Catalog {
"@plural" => {
c.plurals.insert(src, tr.split('|').map(str::to_string).collect());
}
"@msg" => {
if src.contains('{') {
c.templates.extend(Template::parse(&src, tr));
} else {
c.messages.insert(src, tr);
}
}
_ => {
c.contextual.insert(format!("{ctx}\u{1}{src}"), tr);
}
}
}
// Most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`.
c.templates.sort_by_key(|t| std::cmp::Reverse(t.literal));
c
}
@@ -94,6 +189,19 @@ impl Catalog {
self.ids.get(id).map(String::as_str)
}
/// A status or error message: an exact `@msg` entry, else the first template it is an instance
/// of, with the placeholder values filled in. The values are returned for the caller to
/// translate (a reason after `…: {e}` is often a message of its own).
pub fn message<'a>(&self, s: &'a str) -> Option<(&str, Vec<(&str, &'a str)>)> {
if let Some(tr) = self.messages.get(s) {
return Some((tr.as_str(), Vec::new()));
}
if s.len() > MAX_MESSAGE_LEN {
return None;
}
self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.translation.as_str(), caps)))
}
/// The plural form `index` of the message whose English forms are `one|other`.
pub fn plural(&self, one: &str, other: &str, index: usize) -> Option<&str> {
let forms = self.plurals.get(&format!("{one}|{other}"))?;
+504
View File
@@ -11,6 +11,7 @@
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
# leading/trailing spaces of fragments exactly as they are.
@@ -3033,3 +3034,506 @@ axis Both Ambos
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
Put back in the toolbar Devolver a la barra de herramientas
Float Tool Group Hacer flotar el grupo de herramientas
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
@msg BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray
@msg Blend colors: select at least three objects with filled colours Fusionar colores: selecciona al menos tres objetos con relleno de color
@msg Combine Slices: the slices have no bounds Combinar sectores: los sectores no tienen límites
@msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área
@msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados
@msg Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
@msg Corners edits a live rectangle Esquinas edita un rectángulo interactivo
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
@msg Couldn't place {_1}: the document it was dropped on was closed No se pudo colocar {_1}: se cerró el documento en el que se soltó
@msg Couldn't read {name}: {e} No se pudo leer {name}: {e}
@msg Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e}
@msg Create Outlines: select text objects Crear contornos: selecciona objetos de texto
@msg Create from Guides: the document has no artboard Crear a partir de guías: el documento no tiene mesa de trabajo
@msg Create from Guides: the guides make more than {MAX_PIECES} slices Crear a partir de guías: las guías forman más de {MAX_PIECES} sectores
@msg Create from Selection: the selection has no area Crear a partir de la selección: la selección no tiene área
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Recuperación de datos no tiene dónde guardar copias: configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder)
@msg Divide Objects Below: select a path Dividir objetos inferiores: selecciona un trazado
@msg Divide Objects Below: select exactly one path Dividir objetos inferiores: selecciona exactamente un trazado
@msg Downloaded {_1} file(s) Se descargaron {_1} archivo(s)
@msg Downloaded {what} Se descargó {what}
@msg Expand Appearance: the selection has a basic appearance Expandir apariencia: la selección tiene una apariencia básica
@msg Expand: no brush strokes selected Expandir: no hay trazos de pincel seleccionados
@msg Expand: no gradient fills Expandir: no hay rellenos de degradado
@msg Expand: nothing to expand Expandir: no hay nada que expandir
@msg Exported {count} file(s) to {folder} Se exportaron {count} archivo(s) a {folder}
@msg Exported {path} Se exportó {path}
@msg Flatten Transparency: select objects to flatten Acoplar transparencia: selecciona los objetos que quieres acoplar
@msg Flatten Transparency: {e} Acoplar transparencia: {e}
@msg Image Trace: the traced image is missing Calco de imagen: falta la imagen calcada
@msg Imported {what}: {_1} Se importó {what}: {_1}
@msg Installed plug-in {_1} Se instaló el plugin {_1}
@msg Internal error: {msg} (please report this bug) Error interno: {msg} (por favor, informa de este error)
@msg Live Paint Merge: select a Live Paint group and the paths to add Combinar pintura interactiva: selecciona un grupo de pintura interactiva y los trazados que quieres agregar
@msg Live Paint: select paths or compound paths Pintura interactiva: selecciona trazados o trazados compuestos
@msg Live Paint: these paths are too degenerate to paint Pintura interactiva: estos trazados están demasiado degenerados para pintarlos
@msg Make Guides: select paths Crear guías: selecciona trazados
@msg No saved selections (Select → Save Selection…) No hay selecciones guardadas (Selección → Guardar selección…)
@msg No saved views (View → New View…) No hay vistas guardadas (Ver → Nueva vista…)
@msg Offset Path: select paths Desplazar trazado: selecciona trazados
@msg Opened with {_1} note(s): {_2} Se abrió con {_1} nota(s): {_2}
@msg Opened {url} Se abrió {url}
@msg Outline Stroke: no stroked paths selected Contornear trazo: no hay trazados con trazo seleccionados
@msg PDF settings are not an object los ajustes de PDF no son un objeto
@msg PDF writer error: {0} error del escritor de PDF: {0}
@msg Packaged {n} file(s) in {folder}{note} Se empaquetaron {n} archivo(s) en {folder}{note}
@msg Packaged {n} file(s){note} Se empaquetaron {n} archivo(s){note}
@msg Preview in Browser needs the desktop app (the web downloads files instead) Previsualizar en el navegador requiere la aplicación de escritorio (la versión web descarga los archivos)
@msg Release Guides: no guides Soltar guías: no hay guías
@msg Release: no layer to put the rectangle in Soltar: no hay ninguna capa donde poner el rectángulo
@msg Replace Spine: select a blend and a path Reemplazar eje: selecciona una fusión y un trazado
@msg SVG parse error: {0} error al analizar el SVG: {0}
@msg Save for Web: the image is empty Guardar para web: la imagen está vacía
@msg Save writes {_1}, not {_2}: use document.export for other formats Guardar escribe {_1}, no {_2}: usa document.export para otros formatos
@msg Saved recovery data for {title} Se guardaron los datos de recuperación de {title}
@msg Saved to User Defined: {_1} Se guardó en Definido por el usuario: {_1}
@msg Saved {path} Se guardó {path}
@msg Select text or click into text with the Type tool to insert glyphs Selecciona texto o haz clic dentro del texto con la herramienta Texto para insertar glifos
@msg Shape Builder: no region under the pointer Generador de formas: no hay ninguna región bajo el puntero
@msg Shape Builder: select paths or compound paths Generador de formas: selecciona trazados o trazados compuestos
@msg Show All Hidden Attributes: nothing is hidden Mostrar todos los atributos ocultos: no hay nada oculto
@msg Split Into Grid: select paths large enough for the grid Dividir en cuadrícula: selecciona trazados lo bastante grandes para la cuadrícula
@msg Targa files are RGB: colorModel rgb los archivos Targa son RGB: colorModel rgb
@msg Text Wrap: select a wrap object Ceñir texto: selecciona un objeto para ceñir
@msg `aspect` must be a number (%) `aspect` debe ser un número (%)
@msg `at` must be [x, y] `at` debe ser [x, y]
@msg `color` must be a colour `color` debe ser un color
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` debe ser un comando cuyo elemento de menú abra un cuadro de diálogo (consulta ui.menuDialog)
@msg `freeform.points` must be an array `freeform.points` debe ser una lista
@msg `gradient` must be an object `gradient` debe ser un objeto
@msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list})
@msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5
@msg `stops` must be an array `stops` debe ser una lista
@msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats)
@msg `{_1}` is no save command `{_1}` no es un comando de guardado
@msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación
@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` no es un color (#rrggbb o el nombre de un preajuste)
@msg `{id}` has no envelope dialog `{id}` no tiene cuadro de diálogo de envolvente
@msg `{keep}` is not a side of this axis `{keep}` no es un lado de este eje
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` debe ser `auto` o un código de idioma como `en` o `zh-hant`
@msg `{key}` must be a #rrggbb colour `{key}` debe ser un color #rrggbb
@msg `{key}` must be a number `{key}` debe ser un número
@msg `{key}` must be a string `{key}` debe ser un texto
@msg `{key}` must be a whole number `{key}` debe ser un número entero
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` debe ser un número entero entre {min} y {max} (se recibió {x})
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` debe estar entre {min} y {max} (se recibió {x})
@msg `{key}` must be one of {_1} `{key}` debe ser uno de estos: {_1}
@msg `{key}` must be true or false `{key}` debe ser true o false
@msg `{k}` must be [x, y] `{k}` debe ser [x, y]
@msg `{name}` has no options `{name}` no tiene opciones
@msg `{name}` is a built-in workspace; choose another name `{name}` es un espacio de trabajo integrado; elige otro nombre
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` es un color de cuatricromía: los matices se aplican a colores globales y planos
@msg `{name}` is no recovery entry name `{name}` no es el nombre de una entrada de recuperación
@msg `{name}` isn't a colour swatch `{name}` no es una muestra de color
@msg `{path}` must be a bool or an object of bools `{path}` debe ser un booleano o un objeto de booleanos
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` no es un color (#rrggbb o el nombre de un preajuste)
@msg `{tool}` isn't a Liquify tool `{tool}` no es una herramienta de Licuar
@msg `{tool}` isn't a freehand tool `{tool}` no es una herramienta de dibujo a mano alzada
@msg `{to}` is not an available name `{to}` no es un nombre disponible
@msg a bitmap is empty un mapa de bits está vacío
@msg a bitmap is too large to read un mapa de bits es demasiado grande para leerlo
@msg a colour index is 0 to {_1}, not {n} un índice de color va de 0 a {_1}, no {n}
@msg a colour is an index, #rrggbb or a preset name, not {v} un color es un índice, #rrggbb o el nombre de un preajuste, no {v}
@msg a document needs at least one artboard un documento necesita al menos una mesa de trabajo
@msg a document needs at least one layer un documento necesita al menos una capa
@msg a freeform gradient keeps at least one point un degradado de forma libre conserva al menos un punto
@msg a gradient needs at least two stops un degradado necesita al menos dos paradas
@msg a graphic style named `{to}` already exists ya existe un estilo gráfico llamado `{to}`
@msg a page is missing falta una página
@msg a page out of range una página fuera de rango
@msg a page tree kid isn't a reference un hijo del árbol de páginas no es una referencia
@msg a page tree node has no kids un nodo del árbol de páginas no tiene hijos
@msg a page's placement is not finite la posición de una página no es finita
@msg a preset named `{_1}` exists ya existe un preajuste llamado `{_1}`
@msg a slice size must be a positive number of points el tamaño de un sector debe ser un número positivo de puntos
@msg a stream runs past the end un flujo se extiende más allá del final
@msg a stream's length is wrong la longitud de un flujo es incorrecta
@msg a stream's length isn't a number la longitud de un flujo no es un número
@msg add a format to export agrega un formato para exportar
@msg already editing a pattern ya se está editando un motivo
@msg an object can't be read no se puede leer un objeto
@msg an object in perspective would leave the edited grid un objeto en perspectiva saldría de la cuadrícula editada
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art o type
@msg artboard {0} does not exist la mesa de trabajo {0} no existe
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard a esta escala el dibujo mide {w} × {h} pt, más de lo que cabe en el lienzo: elige una escala menor o ajústalo a la mesa de trabajo
@msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800"
@msg bad color {v} color no válido {v}
@msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb)
@msg bad page tree árbol de páginas no válido
@msg bad shortcut atajo no válido
@msg bad stream length longitud de flujo no válida
@msg bad trailer trailer no válido
@msg balance must be between 0 and 100 el equilibrio debe estar entre 0 y 100
@msg batch step {_1} (`{id}`) failed: {e} falló el paso {_1} del lote (`{id}`): {e}
@msg brightness must be dark|mediumDark|mediumLight|light el brillo debe ser dark|mediumDark|mediumLight|light
@msg can't encode the document: {0} no se puede codificar el documento: {0}
@msg can't parse shortcut `{c}` no se puede interpretar el atajo `{c}`
@msg can't parse shortcut `{s}` for `{k}` no se puede interpretar el atajo `{s}` de `{k}`
@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} no se puede guardar la versión {0}: VectorCraft escribe las versiones {MIN_VERSION} a {VERSION}
@msg can't write .{ext} images no se pueden escribir imágenes .{ext}
@msg cannot distort a zero-size bounding box no se puede distorsionar un cuadro delimitador de tamaño cero
@msg cannot read PDF: {0} no se puede leer el PDF: {0}
@msg cannot split an open path at its end point no se puede dividir un trazado abierto en su punto final
@msg check an asset to export marca un recurso para exportar
@msg choice must be "merge" or "add", not `{choice}` choice debe ser "merge" o "add", no `{choice}`
@msg choose a direction to divide in elige una dirección en la que dividir
@msg choose a folder to export to elige una carpeta a la que exportar
@msg choose a location for the package elige una ubicación para el paquete
@msg clipboard is empty el portapapeles está vacío
@msg collapsed must be true or false collapsed debe ser true o false
@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` no es un color como "#ff8800"
@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: como máximo {MAX_TABLE} colores
@msg command `{0}` is not available right now: {1} el comando `{0}` no está disponible ahora: {1}
@msg compound paths can only contain paths los trazados compuestos solo pueden contener trazados
@msg compressed (RLE) bitmaps can't be read yet todavía no se pueden leer mapas de bits comprimidos (RLE)
@msg damaged dañado
@msg decimals must be {_1}–{_2} (got {_3}) los decimales deben ser {_1}–{_2} (se recibió {_3})
@msg divide into 1 to {MAX_PIECES} slices divide en 1 a {MAX_PIECES} sectores
@msg effect {k} is `{_1}`, not `{id}` el efecto {k} es `{_1}`, no `{id}`
@msg empty brush stroke trazo de pincel vacío
@msg empty cutter cortador vacío
@msg empty path trazado vacío
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION})
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
@msg give corners 0–3 indica esquinas de 0 a 3
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
@msg guides are locked las guías están bloqueadas
@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió
@msg image data `{_1}` is missing faltan los datos de imagen `{_1}`
@msg image would be too large; lower the resolution la imagen sería demasiado grande; baja la resolución
@msg index must be a number or first, previous, next or last index debe ser un número o first, previous, next o last
@msg index must be a number or first, previous, next or last, not `{other}` index debe ser un número o first, previous, next o last, no `{other}`
@msg installing from a path is not available on the web; pass the module as `dataBase64` en la web no se puede instalar desde una ruta; pasa el módulo como `dataBase64`
@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) error interno en `{cmd}`: {msg} (el documento quedó como estaba; por favor, informa de este error)
@msg internal error: {msg} (please report this bug) error interno: {msg} (por favor, informa de este error)
@msg invalid PDF setting: {0} ajuste de PDF no válido: {0}
@msg invalid image data for `{0}` datos de imagen no válidos para `{0}`
@msg invalid parameters for `{cmd}`: {msg} parámetros no válidos para `{cmd}`: {msg}
@msg is not an image VectorCraft reads no es una imagen que VectorCraft pueda leer
@msg join needs open paths unir necesita trazados abiertos
@msg lang must be one of {_1} lang debe ser uno de estos: {_1}
@msg layers can't be wrap objects las capas no pueden ser objetos para ceñir
@msg missing `folder` falta `folder`
@msg missing `id` (see plugin.list) falta `id` (consulta plugin.list)
@msg missing `name` falta `name`
@msg missing `tool` falta `tool`
@msg missing index falta index
@msg multiple windows land with M11.5 las ventanas múltiples llegan con M11.5
@msg name the file ponle nombre al archivo
@msg no CSS to copy: select objects with CSS of their own no hay CSS que copiar: selecciona objetos con CSS propio
@msg no Live Paint edge near that point no hay ningún borde de pintura interactiva cerca de ese punto
@msg no Live Paint face at that point no hay ninguna cara de pintura interactiva en ese punto
@msg no PNG encoder no hay codificador PNG
@msg no active document no hay ningún documento activo
@msg no active perspective plane no hay ningún plano de perspectiva activo
@msg no anchor points selected no hay puntos de ancla seleccionados
@msg no app to open files here aquí no hay ninguna aplicación para abrir archivos
@msg no artboard no hay mesa de trabajo
@msg no artboard {i} (0-based; the document has {_1}) no existe la mesa de trabajo {i} (contando desde 0; el documento tiene {_1})
@msg no artboard {i} (0-based; the document has {count}) no existe la mesa de trabajo {i} (contando desde 0; el documento tiene {count})
@msg no artboards named no se indicaron mesas de trabajo
@msg no artwork no hay ilustración
@msg no blend options no hay opciones de fusión
@msg no bounds sin límites
@msg no brush named `{name}` no hay ningún pincel llamado `{name}`
@msg no catalog no hay catálogo
@msg no changes since the last save no hay cambios desde la última vez que se guardó
@msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad
@msg no colour group `{g}` no existe el grupo de colores `{g}`
@msg no cross-reference table no hay tabla de referencias cruzadas
@msg no current layer no hay capa actual
@msg no data no hay datos
@msg no dialog open no hay ningún cuadro de diálogo abierto
@msg no document no hay documento
@msg no document open no hay ningún documento abierto
@msg no effect applied yet todavía no se aplicó ningún efecto
@msg no effect at index {k} no hay ningún efecto en el índice {k}
@msg no end record no hay registro final
@msg no file manager here aquí no hay administrador de archivos
@msg no file reader no hay lector de archivos
@msg no file system here aquí no hay sistema de archivos
@msg no file writer no hay escritor de archivos
@msg no files to place no hay archivos para colocar
@msg no format to export to no hay formato al que exportar
@msg no free recovery area no hay ningún área de recuperación libre
@msg no gradient stop or freeform point is selected no hay ninguna parada de degradado ni punto de forma libre seleccionado
@msg no graphic style `{name}` no existe el estilo gráfico `{name}`
@msg no graphic style `{n}` no existe el estilo gráfico `{n}`
@msg no interaction in progress no hay ninguna interacción en curso
@msg no key object no hay objeto clave
@msg no library `{key}` (see {list}) no existe la biblioteca `{key}` (consulta {list})
@msg no native format no hay formato nativo
@msg no pages no hay páginas
@msg no pattern `{name}` no existe el motivo `{name}`
@msg no perspective grid preset named `{name}` no hay ningún preajuste de cuadrícula de perspectiva llamado `{name}`
@msg no plug-in {0} is installed no hay ningún plugin {0} instalado
@msg no plug-in {id} is installed no hay ningún plugin {id} instalado
@msg no point {i} (the gradient has {_1}) no existe el punto {i} (el degradado tiene {_1})
@msg no preset editor no hay editor de preajustes
@msg no previous transform no hay transformación anterior
@msg no print preset named `{base}` no hay ningún preajuste de impresión llamado `{base}`
@msg no print preset named `{name}` no hay ningún preajuste de impresión llamado `{name}`
@msg no print service no hay servicio de impresión
@msg no revert command no hay comando para volver a la versión guardada
@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) no hay ningún preajuste de PDF guardado llamado `{name}` (los preajustes integrados son de solo lectura: crea uno nuevo a partir de ellos)
@msg no saved presets no hay preajustes guardados
@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) no hay ningún preajuste de impresión guardado llamado `{name}` ({DEFAULT_PRESET} está protegido: crea uno nuevo a partir de él)
@msg no saved profiles no hay perfiles guardados
@msg no saved selections no hay selecciones guardadas
@msg no saved themes no hay temas guardados
@msg no segment {segment} on line {line} no existe el segmento {segment} en la línea {line}
@msg no selected slice in the image: select slices, or save all of them no hay ningún sector seleccionado en la imagen: selecciona sectores o guárdalos todos
@msg no selection bounds la selección no tiene límites
@msg no slice selected no hay ningún sector seleccionado
@msg no such action no existe esa acción
@msg no such anchor no existe ese punto de ancla
@msg no such artboard no existe esa mesa de trabajo
@msg no such document no existe ese documento
@msg no such layer or object no existe esa capa u objeto
@msg no such object {0} no existe el objeto {0}
@msg no such perspective grid preset no existe ese preajuste de cuadrícula de perspectiva
@msg no such recent file no existe ese archivo reciente
@msg no such recent font no existe esa fuente reciente
@msg no such saved selection no existe esa selección guardada
@msg no such segment no existe ese segmento
@msg no such subpath no existe ese subtrazado
@msg no such user library no existe esa biblioteca de usuario
@msg no such view no existe esa vista
@msg no such width point no existe ese punto de anchura
@msg no swatch `{name}` no existe la muestra `{name}`
@msg no swatch library `{l}` (see swatch.library.list) no existe la biblioteca de muestras `{l}` (consulta swatch.library.list)
@msg no symbol instances selected no hay instancias de símbolo seleccionadas
@msg no target object has an opacity mask: select one or give `id` ningún objeto de destino tiene máscara de opacidad: selecciona uno o indica `id`
@msg no threaded text selected no hay texto enlazado seleccionado
@msg no user workspace `{from}` no existe el espacio de trabajo de usuario `{from}`
@msg node {_1} is not text el nodo {_1} no es texto
@msg not a DXF drawing no es un dibujo DXF
@msg not a VectorCraft file: {0} no es un archivo de VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) no es un conjunto de atajos de VectorCraft (falta `overrides`)
@msg not a graphic style library (format `{_1}`) no es una biblioteca de estilos gráficos (formato `{_1}`)
@msg not a stored central record no es un registro central almacenado
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
@msg not an EMF or WMF file no es un archivo EMF ni WMF
@msg not found no se encontró
@msg not in pattern editing mode no está en el modo de edición de motivos
@msg nothing encoded no se codificó nada
@msg nothing selected no hay nada seleccionado
@msg nothing to align no hay nada que alinear
@msg nothing to distort no hay nada que distorsionar
@msg nothing to export: the document has no visible art (or turn on Use Artboards) no hay nada que exportar: el documento no tiene ilustración visible (o activa Usar mesas de trabajo)
@msg nothing to preview no hay nada que previsualizar
@msg nothing to print no hay nada que imprimir
@msg nothing to redo no hay nada que rehacer
@msg nothing to save: the document has no visible art no hay nada que guardar: el documento no tiene ilustración visible
@msg nothing to undo no hay nada que deshacer
@msg nothing to warp no hay nada que deformar
@msg nothing to wrap no hay nada que envolver
@msg object {0} cannot have children el objeto {0} no puede tener hijos
@msg object {id} is not a path el objeto {id} no es un trazado
@msg page size {w} × {h} tamaño de página {w} × {h}
@msg page {0} does not exist (the PDF has {1}) la página {0} no existe (el PDF tiene {1})
@msg paste unavailable pegar no está disponible
@msg path is already closed el trazado ya está cerrado
@msg paths contain invalid coordinates los trazados contienen coordenadas no válidas
@msg paths: give at least one file paths: indica al menos un archivo
@msg pattern missing falta el motivo
@msg percent must be above 0 and at most 6400, not {p} el porcentaje debe ser mayor que 0 y como máximo 6400, no {p}
@msg pick a colour, gradient or pattern to save as a swatch elige un color, degradado o motivo para guardarlo como muestra
@msg plane must be left, right or ground plane debe ser left, right o ground
@msg plug-in ABI: {0} ABI del plugin: {0}
@msg plug-in exceeded its {0} el plugin superó su {0}
@msg plug-in failed: {0} el plugin falló: {0}
@msg plug-in manifest: {0} manifiesto del plugin: {0}
@msg plug-in output: {0} salida del plugin: {0}
@msg plug-in parameters: {0} parámetros del plugin: {0}
@msg plug-in trapped: {0} el plugin se detuvo con un error: {0}
@msg print settings are not an object los ajustes de impresión no son un objeto
@msg printing isn't available here aquí no se puede imprimir
@msg range `{s}` is empty el rango `{s}` está vacío
@msg range `{s}`: `{part}` names no number rango `{s}`: `{part}` no indica ningún número
@msg range `{s}`: `{part}` runs backwards rango `{s}`: `{part}` va hacia atrás
@msg range `{s}`: `{t}` is not a number from 1 to {count} rango `{s}`: `{t}` no es un número de 1 a {count}
@msg recovery copy `{file}`: {e} copia de recuperación `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi las resoluciones deben estar entre 1 y 2400 ppi
@msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango)
@msg scale must be a number from {_1} to {_2}, not {_3} la escala debe ser un número de {_1} a {_2}, no {_3}
@msg scale must be a number from {_1} to {_2}, not {s} la escala debe ser un número de {_1} a {_2}, no {s}
@msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica
@msg select a Live Paint group selecciona un grupo de pintura interactiva
@msg select a blend selecciona una fusión
@msg select a clip group selecciona un grupo de recorte
@msg select a gradient mesh selecciona una malla de degradado
@msg select a linked image: an embedded one has no file selecciona una imagen vinculada: una incrustada no tiene archivo
@msg select a repeat selecciona una repetición
@msg select a stroke with a variable width selecciona un trazo con anchura variable
@msg select a symbol instance selecciona una instancia de símbolo
@msg select an Image Trace object selecciona un objeto de calco de imagen
@msg select an envelope selecciona una envolvente
@msg select an image selecciona una imagen
@msg select an object in perspective selecciona un objeto en perspectiva
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
@msg select at least two objects selecciona al menos dos objetos
@msg select objects, not layers selecciona objetos, no capas
@msg select one live rectangle, or give its `id` selecciona un rectángulo interactivo o indica su `id`
@msg select paths to join selecciona los trazados que quieres unir
@msg select the art to repeat selecciona la ilustración que quieres repetir
@msg select the artwork to warp selecciona la ilustración que quieres deformar
@msg select the objects to transform in perspective selecciona los objetos que quieres transformar en perspectiva
@msg select two or more graphic styles to merge selecciona dos o más estilos gráficos para combinarlos
@msg select type in perspective selecciona texto en perspectiva
@msg selection has no bounds la selección no tiene límites
@msg shortcut for `{k}` must be a string el atajo de `{k}` debe ser un texto
@msg stop {i} needs `offset` la parada {i} necesita `offset`
@msg stop {i} needs a valid `color` la parada {i} necesita un `color` válido
@msg stop {i}: `{k}` must be a number parada {i}: `{k}` debe ser un número
@msg the EMF header is damaged el encabezado EMF está dañado
@msg the PDF has no pages el PDF no tiene páginas
@msg the PDF is no longer loaded el PDF ya no está cargado
@msg the PDF is password-protected: give its password el PDF está protegido con contraseña: indica su contraseña
@msg the PDF password is wrong la contraseña del PDF es incorrecta
@msg the Save PDF dialog didn't open el cuadro de diálogo Guardar PDF no se abrió
@msg the WMF header is damaged el encabezado WMF está dañado
@msg the art could not be sampled no se pudo muestrear la ilustración
@msg the art has no bounds la ilustración no tiene límites
@msg the art is too light for any dots la ilustración es demasiado clara para generar puntos
@msg the artboard to fit must be a positive size up to {_1} pt la mesa de trabajo a la que ajustar debe tener un tamaño positivo de hasta {_1} pt
@msg the background worker stopped el proceso en segundo plano se detuvo
@msg the compressed file unpacks to more than {_1} MB el archivo comprimido ocupa más de {_1} MB al descomprimirse
@msg the document has never been saved el documento nunca se guardó
@msg the document has no artboard el documento no tiene mesa de trabajo
@msg the document has no artboards el documento no tiene mesas de trabajo
@msg the document has no patterns el documento no tiene motivos
@msg the document has no slices el documento no tiene sectores
@msg the document has no symbols el documento no tiene símbolos
@msg the drawing has no layout `{_1}` (it has {_2}) el dibujo no tiene la presentación `{_1}` (tiene {_2})
@msg the drawing is no longer loaded el dibujo ya no está cargado
@msg the drawing is too large to open el dibujo es demasiado grande para abrirlo
@msg the export region is empty la región de exportación está vacía
@msg the export region is not finite la región de exportación no es finita
@msg the export returned unreadable data la exportación devolvió datos ilegibles
@msg the export wrote no file la exportación no escribió ningún archivo
@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG el archivo solo contiene una página provisional, no su ilustración (se guardó sin compatibilidad con PDF): vuelve a guardarlo con la compatibilidad con PDF activada, o como PDF o SVG
@msg the file's preview header points outside the file el encabezado de previsualización del archivo apunta fuera del archivo
@msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño
@msg the layer has no objects to clip by la capa no tiene objetos con los que recortar
@msg the layer or group is locked la capa o el grupo está bloqueado
@msg the layer to flatten into is hidden la capa en la que acoplar está oculta
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
@msg the object has no geometry el objeto no tiene geometría
@msg the object has no opacity mask el objeto no tiene máscara de opacidad
@msg the object has no stroke el objeto no tiene trazo
@msg the object is beyond the plane's horizon el objeto está más allá del horizonte del plano
@msg the object isn't in perspective el objeto no está en perspectiva
@msg the object isn't on a perspective plane el objeto no está en un plano de perspectiva
@msg the object would cross the horizon of the perspective plane el objeto cruzaría el horizonte del plano de perspectiva
@msg the objects would leave the grid los objetos saldrían de la cuadrícula
@msg the package came back damaged el paquete llegó dañado
@msg the pointer is beyond the plane's horizon el puntero está más allá del horizonte del plano
@msg the preview image is empty la imagen de previsualización está vacía
@msg the print job has no data el trabajo de impresión no tiene datos
@msg the scale is not a usable number la escala no es un número utilizable
@msg the selected stroke's profile is already listed as {_1} el perfil del trazo seleccionado ya figura como {_1}
@msg the selection has no URL (or they differ) la selección no tiene URL (o son distintas)
@msg the selection isn't in perspective la selección no está en perspectiva
@msg the stroke has no weight el trazo no tiene grosor
@msg the thumbnail image is malformed la miniatura está mal formada
@msg the top object must be a path, compound path or text object to use as a clipping mask el objeto superior debe ser un trazado, un trazado compuesto o un objeto de texto para usarlo como máscara de recorte
@msg the {tool} tool has no options la herramienta {tool} no tiene opciones
@msg there are no objects to release no hay objetos que soltar
@msg there are no printer settings to open here aquí no hay ajustes de impresora que abrir
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({_1}): guárdalo como PDF o SVG en la aplicación que lo creó
@msg this format holds one artboard: export several as PDF or with document.exportForScreens este formato admite una sola mesa de trabajo: exporta varias como PDF o con document.exportForScreens
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF ASCII y abre ese archivo
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG este es un archivo PostScript (un .ai guardado en un formato antiguo o sin compatibilidad con PDF, o un archivo EPS), que todavía no se puede abrir: guárdalo como PDF, .ai compatible con PDF o SVG
@msg this is not a PostScript file este no es un archivo PostScript
@msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift)
@msg truncated truncado
@msg unknown command `{0}` comando desconocido `{0}`
@msg unknown effect `{id}` efecto desconocido `{id}`
@msg unknown format formato desconocido
@msg unknown format `{f}` (Save writes {_1}) formato desconocido `{f}` (Guardar escribe {_1})
@msg unknown freeform mode `{m}` (points, lines) modo de forma libre desconocido `{m}` (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, freeform) tipo de degradado desconocido `{k}` (linear, radial, freeform)
@msg unknown highlight `{h}` resaltado desconocido `{h}`
@msg unknown option `{path}.{k}` opción desconocida `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard)
@msg unknown panel `{raw}` panel desconocido `{raw}`
@msg unknown preference `{key}` preferencia desconocida `{key}`
@msg unknown preset (one of {_1}) preajuste desconocido (uno de estos: {_1})
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preajuste desconocido `{name}` (high, medium, low o uno guardado: consulta flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) preajuste desconocido `{name}` (consulta flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) preajuste desconocido `{name}` (consulta webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) lado desconocido `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` herramienta desconocida `{tool}`
@msg unknown tool `{t}` herramienta desconocida `{t}`
@msg unknown tool or command `{key}` herramienta o comando desconocido `{key}`
@msg unknown workspace (one of {_1}) espacio de trabajo desconocido (uno de estos: {_1})
@msg unsupported: {0} no compatible: {0}
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files los archivos de la versión {0} no se pueden comprimir: solo la versión {COMPRESSED_SINCE} y posteriores abren archivos comprimidos
@msg width and height are at least 1 pixel el ancho y el alto son de al menos 1 píxel
@msg workspace name can't be empty el nombre del espacio de trabajo no puede estar vacío
@msg {C}: no effect at index {from} {C}: no hay ningún efecto en el índice {from}
@msg {_1} failed: {e} {_1} falló: {e}
@msg {_1} files can be opened but not written (see document.formats) los archivos {_1} se pueden abrir pero no escribir (consulta document.formats)
@msg {_1} font families available {_1} familias de fuentes disponibles
@msg {_1} is a live effect (Effect › Plug-ins) {_1} es un efecto interactivo (Efecto › Plugins)
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
@msg {command} didn't create an object {command} no creó ningún objeto
@msg {label} produced an empty result {label} produjo un resultado vacío
@msg {label}: lost track of a shape's style {label}: se perdió el estilo de una forma
@msg {label}: select paths {label}: selecciona trazados
@msg {label}: select paths, compound paths, groups or text {label}: selecciona trazados, trazados compuestos, grupos o texto
@msg {label}… {label}…
@msg {name}: CRC mismatch {name}: el CRC no coincide
@msg {name}: no local header {name}: no hay encabezado local
@msg {name}: no such recovery entry {name}: no existe esa entrada de recuperación
@msg {path} can't be found: relink it first no se encuentra {path}: vuelve a vincularlo primero
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: aquí no hay sistema de archivos (pasa dataBase64 para abrir; omite la ruta para obtener dataBase64)
@msg {path}: {e} {path}: {e}
@msg {status} ({e}) {status} ({e})
@msg Couldn't place {name}: {e} No se pudo colocar {name}: {e}
@msg Couldn't paste from the clipboard: {e} No se pudo pegar desde el portapapeles: {e}
@msg Saved {path} with {_1} note(s): {first} Se guardó {path} con {_1} nota(s): {first}
@msg Exported {what} with {_1} note(s): {first} Se exportó {what} con {_1} nota(s): {first}
@msg Exported {what} Se exportó {what}
@msg Saved {n} files beside {path} Se guardaron {n} archivos junto a {path}
@msg Saving Guardando
@msg Saving {_1} Guardando {_1}
@msg Exporting {_1} Exportando {_1}
@msg Saving recovery data for {_1} Guardando datos de recuperación de {_1}
@msg Discarded {_1} recovered document(s) Se descartaron {_1} documento(s) recuperado(s)
@msg Restored {_1} recovered document(s): save them to keep them Se restauraron {_1} documento(s) recuperado(s): guárdalos para conservarlos
@msg Embedded {n} image(s); {missing} missing: relink them first Se incrustaron {n} imagen(es); faltan {missing}: vuelve a vincularlas primero
@msg Embedded {n} image(s) Se incrustaron {n} imagen(es)
@msg Relinked {_1} image(s) Se volvieron a vincular {_1} imagen(es)
@msg Relinked {n} image(s) Se volvieron a vincular {n} imagen(es)
@msg Relinked {n} image(s); not in that folder: {_1} Se volvieron a vincular {n} imagen(es); no están en esa carpeta: {_1}
@msg Updated {_1} image(s) Se actualizaron {_1} imagen(es)
@msg Unembedded to {_1} Se desincrustó en {_1}
@msg Reset {_1} Se restableció {_1}
@msg Sent to {_1} Se envió a {_1}
@msg {_1} files {_1} archivos
@msg The graphics were lost and have been restarted Se perdieron los gráficos y se reiniciaron
@msg Loading VectorCraft… Cargando VectorCraft…
@msg VectorCraft failed to start: {e} VectorCraft no pudo iniciarse: {e}
@msg A browser with WebGPU or WebGL2 is required. Se necesita un navegador con WebGPU o WebGL2.
@msg The graphics were lost and couldn't be restarted. Se perdieron los gráficos y no se pudieron reiniciar.
@msg Your unsaved changes are kept in this browser: reload the page to get them back. Tus cambios sin guardar se conservan en este navegador: vuelve a cargar la página para recuperarlos.
@msg Reload the page to go on (changes not saved yet may be lost). Vuelve a cargar la página para continuar (los cambios que aún no se guardaron podrían perderse).
@msg floating must be true or false floating debe ser true o false
@msg tool (a tool id) is required se requiere tool (un id de herramienta)
@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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+52 -1
View File
@@ -19,6 +19,10 @@
//! translation survives rewording of the English text and can differ per command.
//! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`];
//! translators may reorder placeholders freely.
//! - [`message`] / [`msg`]: a status or error message, translated only where it is shown. The
//! message itself stays English (the control channel, MCP and tests read it); `@msg` catalog
//! rows hold whole messages or templates such as `Couldn't open {name}: {e}`, and the values in
//! the placeholders are translated in turn (the reason after `: {e}` is often a message too).
mod catalog;
@@ -212,16 +216,28 @@ pub fn detect_system_lang_in_background() {
}
}
/// The system language, worked out once.
static SYSTEM: OnceLock<Lang> = OnceLock::new();
/// The system language (cached). English when it can't be determined.
pub fn system_lang() -> Lang {
// Tests drive the UI by its English labels whatever the developer's locale is.
if cfg!(test) {
return Lang::EN;
}
static SYSTEM: OnceLock<Lang> = OnceLock::new();
*SYSTEM.get_or_init(detect_system_lang)
}
/// Tell the UI the system's preferred languages, most preferred first, where it can't find them
/// itself: the web shell passes the browser's (`navigator.languages`). The first supported one is
/// the system language (English if none is); call it before the first frame. Has no effect once
/// the system language is known.
pub fn set_system_locales<S: AsRef<str>>(tags: &[S]) {
let lang = tags.iter().find_map(|t| lang_from_tag(t.as_ref())).unwrap_or(Lang::EN);
// Already set means already detected (or reported): the first answer stands.
let _ = SYSTEM.set(lang);
}
#[cfg(not(target_arch = "wasm32"))]
fn detect_system_lang() -> Lang {
// `VECTORCRAFT_LOCALE` overrides everything (also handy for screenshots); then the POSIX
@@ -277,6 +293,7 @@ fn registry_locale(text: &str) -> Option<String> {
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
}
/// The browser's languages arrive through [`set_system_locales`]; without them, English.
#[cfg(target_arch = "wasm32")]
fn detect_system_lang() -> Lang {
Lang::EN
@@ -351,6 +368,40 @@ pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str {
if built_in { tr(lang, s) } else { s }
}
/// How deep [`message`] translates placeholder values that are messages themselves.
const MESSAGE_DEPTH: usize = 3;
/// Translate a status or error message for display (see the module docs). Unknown messages, and
/// parts of them (file names, numbers, a reason with no entry), stay as they are.
pub fn message(lang: Lang, s: &str) -> String {
message_at(lang, s, MESSAGE_DEPTH)
}
fn message_at(lang: Lang, s: &str, depth: usize) -> String {
let Some((template, caps)) = lang.catalog().message(s) else { return s.to_string() };
let values: Vec<(&str, String)> =
caps.into_iter().map(|(k, v)| (k, if depth > 1 { message_at(lang, v, depth - 1) } else { v.to_string() })).collect();
let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
fmt(template, &args)
}
/// [`message`] in the current language, remembering the last answer: the status bar asks for the
/// same message every frame.
pub fn msg(s: &str) -> String {
static LAST: std::sync::Mutex<Option<(&'static str, String, String)>> = std::sync::Mutex::new(None);
let lang = current();
let mut last = LAST.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((code, src, out)) = last.as_ref()
&& *code == lang.code()
&& src == s
{
return out.clone();
}
let out = message(lang, s);
*last = Some((lang.code(), s.to_string(), out.clone()));
out
}
/// [`trn`] in the current language.
pub fn tn(n: u64, one: &str, other: &str) -> String {
trn(current(), n, one, other)
+147
View File
@@ -601,3 +601,150 @@ fn a_language_saved_by_an_older_version_carries_over() {
crate::prefs_dialog::restore(&mut app);
assert_eq!(app.session.prefs.interface_language, "cs");
}
#[test]
fn messages_translate_by_template_and_their_parts() {
let c = Catalog::parse(concat!(
"@msg\tnothing selected\tnada seleccionado\n",
"@msg\t{path}: {e}\t{path}: {e}\n",
"@msg\tCouldn't open {name}: {e}\tNo se pudo abrir {name}: {e}\n",
"@msg\tcommand `{0}` is not available right now: {1}\tel comando `{0}` no está disponible ahora: {1}\n",
"@msg\t{a}{b}\tnever\n",
"@msg\t{n} of {total}\t{total} contiene {n}\n",
));
assert_eq!(c.message("nothing selected").map(|m| m.0), Some("nada seleccionado"));
// The template with the most literal text wins over the generic `{path}: {e}`.
let (tr, caps) = c.message("Couldn't open a: b.svg: damaged").unwrap();
assert_eq!(tr, "No se pudo abrir {name}: {e}");
assert_eq!(caps, [("name", "a"), ("e", "b.svg: damaged")], "a placeholder takes the shortest text that lets the rest match");
assert_eq!(c.message("x of y").unwrap().1, [("n", "x"), ("total", "y")]);
assert!(c.message("of y").is_none(), "a placeholder is never empty");
assert!(c.message("something else").is_none());
assert!(c.message(&"x".repeat(5000)).is_none());
let es = Lang::from_code("es").unwrap();
assert_eq!(message(es, "no such message"), "no such message");
// A reason inside a message is translated too, and parts without entries stay as they are.
let nested = "command `object.group` is not available right now: nothing selected";
let shown = message(es, nested);
assert!(shown.contains("`object.group`") && !shown.contains("nothing selected") && !shown.contains("not available"), "{shown}");
assert_eq!(message(Lang::EN, nested), nested);
}
/// Each language that translates status and error messages ([`COMPLETE_MESSAGES`]) has an `@msg`
/// row for every message in the sources that reach the status bar, so a new one can't ship
/// untranslated by accident (`VECTORCRAFT_I18N_DUMP_MESSAGES=<file>` lists them).
#[test]
fn complete_languages_translate_every_message() {
let messages = message_literals();
assert!(messages.len() > 300, "scan found only {} messages", messages.len());
if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") {
std::fs::write(path, messages.iter().map(|m| format!("{m}\n")).collect::<String>()).expect("write dump");
}
for code in COMPLETE_MESSAGES {
let lang = Lang::from_code(code).expect("registered");
let (entries, _) = parse_entries(lang.0.source);
let rows: std::collections::HashSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect();
let missing: Vec<_> = messages.iter().filter(|m| !rows.contains(m.as_str())).collect();
assert!(missing.is_empty(), "{code}: {} untranslated messages: {missing:#?}", missing.len());
}
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["es"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad"];
/// Where a message literal starts: a status, an error value, a `thiserror` message.
const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Other(", "Err(", "ok_or(", "ok_or_else(|| ", "#[error("];
/// Messages that are signals, not text for people.
const NOT_MESSAGES: &[&str] = &["quit", "cancelled"];
/// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog
/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`).
pub fn message_literals() -> std::collections::BTreeSet<String> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut out = std::collections::BTreeSet::new();
let mut stack: Vec<_> = MESSAGE_CRATES.iter().map(|c| crates.join(c).join("src")).collect();
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if name != "tests" {
stack.push(path);
}
} else if name.ends_with(".rs") && !name.starts_with("tests") {
let text = std::fs::read_to_string(&path).unwrap_or_default();
let code = without_test_module(&text);
for marker in MESSAGE_MARKERS {
for (at, _) in code.match_indices(marker) {
let rest = code[at + marker.len()..].trim_start();
let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start);
let Some(lit) = rest.strip_prefix('"').and_then(string_literal) else { continue };
let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase));
if has_word && !NOT_MESSAGES.contains(&lit.as_str()) {
out.insert(as_template(&lit));
}
}
}
}
}
}
out
}
/// Source up to its inline test module (`#[cfg(test)] mod tests {`); declarations of test files
/// (`#[cfg(test)] mod tests_x;`) don't end it.
fn without_test_module(text: &str) -> &str {
for (at, m) in text.match_indices("#[cfg(test)]") {
let after = text[at + m.len()..].trim_start();
if let Some(rest) = after.strip_prefix("mod ")
&& rest.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_').trim_start().starts_with('{')
{
return &text[..at];
}
}
text
}
/// The text of a string literal whose opening quote was just read, escapes resolved.
fn string_literal(s: &str) -> Option<String> {
let mut out = String::new();
let mut it = s.chars();
loop {
match it.next()? {
'"' => return Some(out),
'\\' => match it.next()? {
'n' => out.push('\n'),
't' => out.push('\t'),
c => out.push(c),
},
c => out.push(c),
}
}
}
/// A format string as a catalog template (see [`message_literals`]).
fn as_template(s: &str) -> String {
let mut out = String::new();
let mut unnamed = 0;
let mut rest = s;
while let Some(a) = rest.find('{') {
out.push_str(&rest[..a]);
let after = &rest[a + 1..];
let Some(b) = after.find('}') else { break };
let name = after[..b].split(':').next().unwrap_or("");
if name.is_empty() {
unnamed += 1;
out.push_str(&format!("{{_{unnamed}}}"));
} else {
out.push_str(&format!("{{{name}}}"));
}
rest = &after[b + 1..];
}
out.push_str(rest);
out
}
+3
View File
@@ -234,6 +234,8 @@ pub struct CanvasCache {
pub print_tiling: Option<PrintTilingCache>,
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
/// The tools' cursors as OS cursor bitmaps.
pub cursors: cursors::Images,
}
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
@@ -367,6 +369,7 @@ impl VectorcraftApp {
slices: None,
print_tiling: None,
selection_box: None,
cursors: Default::default(),
},
perf: Perf::default(),
integrated_titlebar: false,
+1 -1
View File
@@ -325,7 +325,7 @@ pub fn drop_files(app: &mut VectorcraftApp, files: Vec<(String, Option<String>,
}),
};
if let Err(e) = r {
app.status(format!("Couldn't {} {name}: {e}", if target == DropTarget::Open { "open" } else { "place" }));
app.status(if target == DropTarget::Open { format!("Couldn't open {name}: {e}") } else { format!("Couldn't place {name}: {e}") });
}
}
}
+15 -5
View File
@@ -109,8 +109,8 @@ international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or
- The language is VectorCraft › Language (the `app.language` UI command, `{lang: auto|<code>}`) or Edit ›
Preferences › User Interface › Language; both set the `interfaceLanguage` preference (`auto` or a language
code; `auto` follows the system locale: `VECTORCRAFT_LOCALE`, then `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
the macOS preferred languages, the Windows user locale). The web build has no locale detection yet and
starts in English. The Preferences dialog previews the chosen language before OK.
the macOS preferred languages, the Windows user locale; on the web, `?lang=<code>` in the address, then
the browser's `navigator.languages`). The Preferences dialog previews the chosen language before OK.
- To add a language: add `<code>.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog, plural
rule). The Language menu, the dropdown, locale matching and the catalog tests (well-formed, no duplicates,
placeholders and ellipses agree, command ids exist, every row of a partial catalog is a string the UI
@@ -119,9 +119,19 @@ international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or
(`VECTORCRAFT_I18N_DUMP=strings.txt cargo test -p vectorcraft-ui-egui dump_source_strings` lists them).
- Translations are clean-room: written from the meaning of the English text in ordinary vocabulary, never
from another product's localisation resources. Product and technology names stay in Latin letters.
- Not translated on purpose: status-bar messages and errors (agents and tests read them), names that are
user data (layers, swatches, fonts, documents), the tab title's colour mode. Not done yet: locale-aware
number and date formats, right-to-left layout, locale detection on the web. Chinese and Japanese UI text
- Status-bar messages and errors stay English where they are made (`app.ui.status`, `EngineError`: the
control channel, MCP and tests read them) and are translated only where the status bar draws them
(`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as
`Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish covers every
message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in
`COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs an `es.tsv`
row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
complete_languages_translate_every_message` lists them all). Other languages show messages in English
until they add `@msg` rows.
- Not translated on purpose: names that are user data (layers, swatches, fonts, documents), the tab
title's colour mode, command ids and parameter names inside messages. Not done yet: locale-aware number
and date formats, right-to-left layout. Chinese and Japanese UI text
is drawn with craft-fonts' BIZ UDPGothic when the build embeds it (see Fonts above), else with an installed
system font; the glyph test checks Latin-script catalogs always and the CJK ones only with craft-fonts.
A Traditional Chinese UI font is still to be added to craft-fonts for the web build.
+6 -3
View File
@@ -1088,9 +1088,12 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
2 bottom-right, 3 bottom-left), else to the corners holding a Direct-Selected anchor (`select.anchors`), else to all
four. Each corner keeps its own radius and kind (the shape's `live` in queries has `radii` and, when a corner isn't
round, `kinds`); a corner with no radius is one anchor, a cut one two, and Direct-Selected corners stay selected as
that changes. With the Selection or Direct Selection tool, dragging a corner widget rounds the corners whose widgets
show (all four, or the Direct-Selected ones), Alt-clicking one cycles their kind and double-clicking one opens Corners
(`ui.corners {id?, corners?}`, dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
that changes. Every corner is a circular arc, whatever the rectangle's proportions: a radius past half the shorter
side draws at half of it (the same limit for every corner), and a rectangle from a file that kept an uneven scale in its
transform (elliptical corners) gets circular ones in document units when its corners are next set. With the Selection or
Direct Selection tool, dragging a corner widget rounds the corners whose widgets show (all four, or the Direct-Selected
ones), outlining them in red once they reach that limit; Alt-clicking one cycles their kind and double-clicking one
opens Corners (`ui.corners {id?, corners?}`, dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
```json
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[1],"radius":16}}}