Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 990d869897 Part of #812: more tools open their options on a double-click
Double-clicking a tool's button (or tool.options {tool}) now also opens:
- Artboard tool: Artboard Options of the active artboard (the Artboard
  tool's while it is in use, else the window's);
- the graph tools: Graph Type for the selected graph (an error without
  one), the dialog Object > Graph > Type opens;
- the Symbolism tools: Symbolism Tools Options with the brush the eight
  tools share (Diameter, Intensity, Symbol Set Density);
- Magic Wand: the Magic Wand panel.

The Object > Graph dialogs' opener is shared with the toolbar. The new
labels are translated in the eight complete catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
bflatasticandClaude Opus 5.5 ae27d899b8 Part of #812: Flare Tool Options last and open from the toolbar
The Flare tool keeps its options (Center: Diameter, Opacity, Brightness;
Halo: Growth, Fuzziness; Rays: on/off, Number, Longest, Fuzziness; Rings:
on/off, Path, Number, Largest, Direction) as persistent tool options,
saved with the preferences. The next flare dragged out uses them (the drag
still sets the centre and diameter, and Up/Down the ray count), a click
with the tool opens them filled in, and double-clicking the tool (or
tool.options {tool: "flare"}) opens them to edit: OK keeps them and draws
nothing. Before, the click dialog was the generic parameter form with
fixed defaults and the drag always used the command's defaults.

The new Flare Tool Options dialog lays the four groups out in two
columns; Rays and Rings have their checkboxes, and every value is kept
within its range. Its labels are translated in the eight complete
catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
bflatasticandClaude Opus 5.5 7c0ce8a382 Part of #812: polygon properties in the Properties and Transform panels
A selected live polygon now shows Illustrator's Polygon Properties in the
Transform panel and the Properties panel's Transform section: side count,
angle, corner type and radius, radius, side length and Make Sides Equal
(enabled once an uneven scale has made its sides differ). Before, only the
side count and corner radius were there.

object.setLiveShape takes polygonAngle (degrees counterclockwise),
polygonRadius and sideLength (points, centre to vertex / side) and
makeSidesEqual, which drops the uneven scale and shear and keeps the
polygon's centre, angle and mean radius. Each call is one undo step; a
radius or length that isn't a number above 0 is an error. A star stays a
plain path, as in Illustrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:00:21 +07:00
BFlat 5305b80308 Merge pull request #899 from vbasky/credit/vbasky-name
Credit vbasky by real and display name
2026-10-10 12:56:25 +07:00
BFlat ba2fc64d89 Merge pull request #906 from storytold/feat/852-pressure-brushes
Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
2026-10-10 12:38:10 +07:00
bflatasticandClaude Opus 5.5 5cf9233d26 Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
Double-clicking a brush in the Brushes panel (or the panel menu's Brush
Options…) opens its options. Calligraphic Brush Options: Angle,
Roundness and Size, each Fixed, Random or Pressure with its Variation,
over live previews of the nib and of a stroke. Bristle Brush Options:
Shape and the bristle sliders. OK runs brush.options; strokes painted
with the brush update.

The Paintbrush records the pen pressure along a stroke (path.freehand
takes [x, y, pressure] points), and a Calligraphic brush with Pressure
variation changes its nib along the stroke, from the value less the
variation at the lightest pressure to the value plus it at the heaviest.
Brushes saved before keep their Random variation. Pen pressure doesn't
reach the app on macOS yet (winit 0.30 reports none there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:15:18 +07:00
Vikram Bhaskaran c81f120ece Credit vbasky by real and display name in the About window 2026-10-10 16:03:48 +11:00
BFlat db23e80ec4 Merge pull request #897 from storytold/fix/886-shift-anchor-guides
Fix #886: Smart Guides keep working on a Shift-constrained anchor drag
2026-10-10 12:00:28 +07:00
BFlat da010c33a1 Merge pull request #896 from storytold/fix/888-smooth-wheel-zoom
Fix #888: wheel notches zoom in a glide with Zoom with Mouse Wheel
2026-10-10 12:00:22 +07:00
bflatasticandClaude Opus 5.5 e47d542680 Fix #886: Smart Guides keep working on a Shift-constrained anchor drag
Dragging anchors with Direct Selection snapped to points, paths and
Smart Guides, but with Shift held the move kept its 45° angle and
snapped to nothing. The grabbed anchor now slides along the constrained
line onto the anchors, paths and alignment lines that cross it, as the
drawing tools' Shift does (counted from General › Constrain Angle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:49:56 +07:00
bflatasticandClaude Opus 5.5 d8759492df Fix #888: wheel notches zoom in a glide with Zoom with Mouse Wheel
With General › Zoom with Mouse Wheel, each wheel notch's zoom was
applied on the frame it arrived, so the view jumped a step per notch
while scrolling glides over a few frames. A mouse wheel's notches now
glide in (what is left shrinks by e every 50 ms), ending at the same
zoom; a trackpad's fine steps still apply at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:49:54 +07:00
BFlat 6ba5e0585e Merge pull request #895 from storytold/fix/867-macos-dialog-thread
Fix #867: macOS file dialogs no longer run inside the window's event handler
2026-10-10 11:49:20 +07:00
BFlat 4c1e16d791 Merge pull request #894 from storytold/fix/890-artboard-edge-art
Fix #890: artboard art includes art flush with its right or bottom edge
2026-10-10 11:49:13 +07:00
bflatasticandClaude Opus 5.5 dd8f144770 Fix #867: macOS file dialogs no longer run inside the window's event handler
On macOS the Open and Save panels ran modally on the UI thread, inside
the window's event handler. Resizing the panel delivered window events
to that handler again, and the app crashed when the mouse was released.
They now open from a thread of their own, as on Linux (#592): rfd runs
the panel on the main thread from the run loop, outside the handler, and
the app picks the answer up when it comes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:30:33 +07:00
bflatasticandClaude Opus 5.5 6c131431ed Fix #890: artboard art includes art flush with its right or bottom edge
The art that goes with an artboard (Duplicate Artboard and Copy/Cut with
art, the Artboard tool's Move/Copy Artwork with Artboard, and moving an
artboard by its X and Y) was found with a half-open containment test, so
an object whose right or bottom edge lay exactly on the artboard's was
left behind. Its edges now count as inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:30:32 +07:00
BFlat 8ae59bf6b7 Merge pull request #892 from storytold/fix/787-precise-export
Fix #787: raster exports keep translucent edges over opaque art opaque
2026-10-10 11:29:36 +07:00
BFlat b47f611e18 Merge pull request #891 from storytold/fix/798-curvature-any-path
Fix #798: the Curvature tool edits any selected path
2026-10-10 11:29:31 +07:00
BFlat 02d02f660f Merge pull request #889 from storytold/pr/roadmap-batch-2026-10-10
Docs: ROADMAP for the 2026-10-10 fixes; Other Library lists color books
2026-10-10 11:12:50 +07:00
bflatasticandClaude Opus 5.5 4099ebf3c9 Fix #787: raster exports keep translucent edges over opaque art opaque
The renderer composited in 8-bit integers, so where a partly transparent
anti-aliased edge crossed opaque art (or a background colour) the result
came out at alpha 254 on some pixels, and PNG, TIFF and WebP files were
written with an alpha channel. Raster exports (and Save for Web) now
composite in floating point (vello_cpu's f32 pipeline, enabled as a
feature), offscreen groups, masks and patterns included; the canvas keeps
the faster 8-bit path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:12:13 +07:00
bflatasticandClaude Opus 5.5 1db82cbc02 Fix #798: the Curvature tool edits any selected path
The Curvature tool only edited the path it was drawing. It now edits any
selected path, whatever drew it, keeping its shape except where edited:
a click on a point makes it the current (direct-selected) point and a
drag moves it, re-curving only its two segments (the neighbours keep
their handle directions); Alt-click or double-click toggles it smooth or
corner and Delete removes it; a click on a segment adds a smooth point
without changing the shape, and a drag moves it in the same undo step.
With an end of an open path current, clicks go on from it (the old
segments keep their shape) and a click on the other end closes it.

The edits go through a new path.curvatureEdit command (move, insert,
extend, close), path.convertAnchor and path.removeAnchor, one undo step
each. The tool now claims Delete/Backspace while it has a point to
remove, so the key no longer deletes the whole path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:12:10 +07:00
bflatasticandClaude Opus 5.5 898c4f7677 Docs: ROADMAP for the 2026-10-10 fixes; Other Library lists color books
The ROADMAP's last-updated line names this batch's fixes and the two
community PRs that didn't add themselves (#792, #765); the platforms
paragraph says Windows starts on Direct3D 12 and loads no Vulkan driver
unless asked (#806); the guides row has ruler guides with Smart Guides
on and the hover labels (#812). Window › Swatch Libraries › Other
Library's description lists .acb color books (#831).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:52:27 +07:00
BFlat 6b42a9100c Merge pull request #887 from storytold/fix/812-hover-labels
Part of #812: Smart Guides label an object's centre and anchors on hover
2026-10-10 10:46:53 +07:00
BFlat 1b398978ba Merge pull request #884 from storytold/fix/827-raster-effect-blend
Fix #827: shadows and glows keep their blend modes in PDF
2026-10-10 10:46:45 +07:00
BFlat 449387a62e Merge pull request #883 from storytold/fix/812-guides-snap
Part of #812: ruler guides pull drawn points with Smart Guides on
2026-10-10 10:46:28 +07:00
BFlat 0782972b88 Merge pull request #882 from storytold/fix/785-step-references
Part of #785: run and command_batch steps can use earlier results
2026-10-10 10:46:10 +07:00
BFlat 07708f6cca Merge pull request #881 from storytold/fix/861-open-error-dialog
Part of #861: a file the user can't open is reported in a dialog
2026-10-10 10:46:01 +07:00
BFlat 5b26fd4d01 Merge pull request #879 from storytold/fix/831-acb-color-books
Fix #831: color books (.acb) open as swatch libraries
2026-10-10 10:45:54 +07:00
BFlat a05a6d06bf Merge pull request #878 from storytold/fix/833-isolation-locks-outside
Fix #833: isolation mode locks and dims the art around the group
2026-10-10 10:45:48 +07:00
BFlat fd59778c8f Merge pull request #877 from storytold/fix/775-properties-paragraph
Fix #775: the Properties panel's Paragraph section justifies and hyphenates
2026-10-10 10:45:43 +07:00
BFlat 06d61296f5 Merge pull request #876 from storytold/fix/789-align-to-everywhere
Fix #789: every align button aligns to the Align To choice
2026-10-10 10:45:36 +07:00
BFlat fb4b84c4b9 Merge pull request #875 from storytold/fix/835-gradient-stop-recent
Fix #835: a recent colour recolours the selected gradient stop
2026-10-10 10:45:30 +07:00
BFlat 9c0eccb067 Merge pull request #874 from storytold/fix/862-stop-popover-beside-art
Fix #862: the gradient stop popover opens beside the art
2026-10-10 10:45:22 +07:00
BFlat 2b01ebcc9e Merge pull request #873 from storytold/fix/826-rotated-rulers
Fix #826: rulers cover their length in a rotated view
2026-10-10 10:45:15 +07:00
BFlat 447fe10edf Merge pull request #872 from storytold/fix/839-sfw-pixel-size
Fix #839: Save for Web accepts a typed width or height
2026-10-10 10:45:09 +07:00
BFlat 9cc0ca866d Merge pull request #871 from storytold/fix/824-artboard-label
Fix #824: the Artboard tool names its artboard once
2026-10-10 10:45:03 +07:00
BFlat 56b3119288 Merge pull request #870 from storytold/fix/806-no-vulkan-on-windows
Fix #806: Windows doesn't load Vulkan drivers unless asked
2026-10-10 10:44:58 +07:00
BFlat 0b249c5257 Merge pull request #869 from storytold/fix/758-skip-hopeless-prune
Part of #758: .ai import doesn't weigh text objects when the layers can't be used
2026-10-10 10:44:52 +07:00
BFlat c1bcd0afeb Merge pull request #863 from storytold/fix/755
Fix #755: blurred type no longer shifts as the view moves
2026-10-10 10:44:41 +07:00
BFlat 5d5db434ea Merge pull request #860 from storytold/fix/830-app-quit-pending
Fix #830: the control channel's app.quit says when it asks first
2026-10-10 10:44:34 +07:00
BFlat 84ae8e90c3 Merge pull request #859 from storytold/fix/828-effect-dropdowns
Fix #828: Offset Path's Joins and Zig Zag's Points are dropdowns
2026-10-10 10:44:27 +07:00
BFlat 71d8881186 Merge pull request #753 from storytold/feat/637-type-from-text-document
Part of #637: area type, threaded type and type on a path from the text document
2026-10-10 10:44:20 +07:00
60 changed files with 3344 additions and 244 deletions

No files matched your search

Generated
+1
View File
@@ -4741,6 +4741,7 @@ dependencies = [
"serde",
"serde_json",
"toml",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
+1 -1
View File
@@ -54,7 +54,7 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
wat = "1.228"
# rendering / formats
vello_cpu = "0.2"
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48"
krilla = "0.8"
+10 -8
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-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -42,7 +42,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows renders through DX12 before Vulkan (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
@@ -67,7 +67,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
composer choice is done), tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 34–47 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
5. **Brushes, symbols and libraries:** brush options depth (Scatter, Art and Pattern Brush Options; Calligraphic
and Bristle are done, Calligraphic with pen pressure); original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and
barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
@@ -128,8 +129,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **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). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come).
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **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). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
- **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. 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). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
@@ -140,7 +141,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
@@ -157,6 +158,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Still to come: Scatter, Art and Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
@@ -216,7 +218,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
| Strokes, brushes, width profiles | 5 | 89% | Scatter, Art and Pattern Brush Options (Calligraphic and Bristle done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 10–18 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges are done | 26–40 |
@@ -227,7 +229,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker, Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740) | 2–4 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
+9 -6
View File
@@ -274,9 +274,12 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
/// inside the window's event handler, and resizing it delivered window events to that handler
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
/// from the run loop, outside the handler.
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
@@ -296,9 +299,9 @@ fn services(parent: Parent) -> Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Windows and macOS dialogs run the window's events while they are open; Linux's don't.
start_pick: cfg!(all(unix, not(target_os = "macos")))
.then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
// ones re-enter the window's event handler (#867).
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
+5
View File
@@ -45,3 +45,8 @@ sync_github_name = false
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
+87 -30
View File
@@ -4,13 +4,21 @@
//! in the directions normal to the path. For nib axes `u`, `v` with semi-axes `a`, `b` the
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
//!
//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
//! sample takes the nib for the pressure there.
use vectorcraft_color::Paint;
use vectorcraft_doc::Node;
use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{Bristle, BristleShape, Calligraphic, filled, tolerance};
use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
/// it takes along a whole subpath.
const MAX_STEPS: f64 = 1024.0;
/// An elliptical nib.
#[derive(Clone, Copy, Debug)]
@@ -43,39 +51,53 @@ fn rot(base: Vec2, angle: f64) -> Vec2 {
base * angle.cos() + normal(base) * angle.sin()
}
/// Samples (point, tangent) along a track, with extra tangents fanned in at corners so the
/// outer edge of the sweep follows the nib around the corner.
fn samples(t: &Track) -> Vec<(Point, Vec2)> {
/// Samples (point, tangent, fraction of the track's length) along a track, with extra tangents
/// fanned in at corners so the outer edge of the sweep follows the nib around the corner. `step`
/// (for a nib that varies along the track): segments are cut into pieces at most that long.
fn samples(t: &Track, step: Option<f64>) -> Vec<(Point, Vec2, f64)> {
let mut out = vec![];
let n = t.segments();
let len = t.len().max(1e-12);
for i in 0..=n {
let p = t.verts[i];
let f = t.cum[i] / len;
if t.corner[i] {
let (a, b) = t.in_out(i);
let turn = a.cross(b).atan2(a.dot(b));
let steps = (turn.abs() / 10f64.to_radians()).ceil().max(1.0) as usize;
for k in 0..=steps {
out.push((p, rot(a, turn * k as f64 / steps as f64)));
out.push((p, rot(a, turn * k as f64 / steps as f64), f));
}
} else {
out.push((p, t.tangents[i]));
out.push((p, t.tangents[i], f));
}
if let Some(step) = step
&& i < n
{
let seg = t.cum[i + 1] - t.cum[i];
let pieces = (seg / step).ceil().clamp(1.0, MAX_STEPS) as usize;
for k in 1..pieces {
let u = k as f64 / pieces as f64;
out.push((p.lerp(t.verts[i + 1], u), t.dirs[i], (t.cum[i] + seg * u) / len));
}
}
}
out
}
/// The swept outline of `nib` along `t` as closed subpaths.
pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
let s = samples(t);
let left: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(-normal(*tg))).collect();
/// The swept outline along `t` of the nib `nib(f)` (`f`: the fraction of the track's length) as
/// closed subpaths. `step`: how often a varying nib is sampled (None: a constant nib).
pub(crate) fn sweep(t: &Track, nib: &dyn Fn(f64) -> Nib, step: Option<f64>) -> Vec<SubPath> {
let s = samples(t, step);
let left: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(-normal(*tg))).collect();
if t.closed {
let mut r = right;
r.reverse();
return vec![SubPath::polyline(&left, true), SubPath::polyline(&r, true)];
}
let cap = |p: Point, tg: Vec2, from: f64, to: f64, out: &mut Vec<Point>| {
let steps = 16;
let cap = |(p, tg, f): (Point, Vec2, f64), from: f64, to: f64, out: &mut Vec<Point>| {
let (steps, nib) = (16, nib(f));
for k in 1..steps {
let ang = from + (to - from) * k as f64 / steps as f64;
out.push(p + nib.support(rot(tg, ang)));
@@ -83,31 +105,66 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
};
let half = std::f64::consts::FRAC_PI_2;
let mut poly = left.clone();
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
cap(pe, te, half, -half, &mut poly);
let (Some(&end), Some(&start)) = (s.last(), s.first()) else { return Vec::new() };
cap(end, half, -half, &mut poly);
poly.extend(right.iter().rev());
cap(ps, ts, -half, -3.0 * half, &mut poly);
cap(start, -half, -3.0 * half, &mut poly);
vec![SubPath::polyline(&poly, true)]
}
fn randomized(c: &Calligraphic, rng: &mut Rng) -> (f64, f64, f64) {
let [va, vr, vs] = c.variation;
let angle = c.angle + rng.range(-va, va);
let round = (c.roundness + rng.range(-vr, vr)).clamp(1.0, 100.0);
let size = (c.size + rng.range(-vs, vs)).max(0.05);
(angle, round, size)
/// The stroke's angle, roundness and size: Random ones drawn for this stroke (from `rng`), the
/// others the brush's values.
fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
let mut out = [c.angle, c.roundness, c.size];
for ((v, var), mode) in out.iter_mut().zip(c.variation).zip(c.modes()) {
// Every value draws, so a stroke's random values stay the same whatever the other modes.
let r = rng.range(-var, var);
if mode == Variation::Random {
*v += r;
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, paint: &Paint, name: &str) -> Vec<Node> {
/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
/// kept within its range; the others are `base`'s.
fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
let values = [c.angle, c.roundness, c.size];
let mut out = base;
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode == Variation::Pressure {
let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
*v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
let mut rng = Rng::new(seed_of(bp, name));
let (angle, round, size) = randomized(c, &mut rng);
let size = size * weight;
let nib = Nib::new(angle, round / 100.0, size);
let subs: Vec<SubPath> = tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib)).collect();
let base = randomized(c, &mut rng);
let subs: Vec<SubPath> = if c.uses_pressure() {
let pressure = stroke.pressure.as_ref();
let nib = |f: f64| {
let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
Nib::new(angle, round / 100.0, size * weight)
};
// Flattened for the largest nib the pressure gives.
let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
} else {
let [angle, round, size] = base;
let nib = Nib::new(angle, round / 100.0, size * weight);
tracks(bp, tolerance(size * weight)).iter().flat_map(|t| sweep(t, &|_| nib, None)).collect()
};
if subs.is_empty() {
return vec![];
}
vec![filled(PathData::new(subs), paint)]
vec![filled(PathData::new(subs), &stroke.paint)]
}
/// Offset a track sideways by `d` (a polyline following it).
@@ -165,7 +222,7 @@ pub(crate) fn bristle(b: &Bristle, bp: &BezPath, weight: f64, paint: &Paint, nam
let Some(sp) = offset_track(&t, off, t0.min(l * 0.3), t1.min(l * 0.3)) else { continue };
let w = thick * (0.6 + 0.4 * edge) * rng.range(0.8, 1.2);
let nib = Nib::new(0.0, 1.0, w);
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &nib)).collect();
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &|_| nib, None)).collect();
if subs.is_empty() {
continue;
}
+1 -1
View File
@@ -132,7 +132,7 @@ fn diamond_tile() -> Node {
}
fn calli(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
/// The default library.
+59 -3
View File
@@ -99,13 +99,69 @@ pub struct Calligraphic {
pub roundness: f64,
/// Nib diameter in points.
pub size: f64,
/// Random variation per stroke: angle (°), roundness (%), size (pt).
/// How far angle (°), roundness (%) and size (pt) vary each way, as [`Self::modes`] say.
pub variation: [f64; 3],
/// How angle, roundness and size vary. `None` (brushes saved before there were modes): Random
/// where the variation is non-zero, else Fixed — see [`Calligraphic::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 3]>,
}
impl Default for Calligraphic {
fn default() -> Self {
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3] }
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3], modes: None }
}
}
impl Calligraphic {
/// The ranges of angle (°), roundness (%) and size (pt), and the most each varies.
pub const RANGES: [(f64, f64); 3] = [(-180.0, 180.0), (0.0, 100.0), (0.0, 1296.0)];
pub const MAX_VARIATION: [f64; 3] = [180.0, 100.0, 1296.0];
/// How angle, roundness and size vary.
pub fn modes(&self) -> [Variation; 3] {
self.modes.unwrap_or_else(|| self.variation.map(|v| if v != 0.0 { Variation::Random } else { Variation::Fixed }))
}
/// Does the pen's pressure change the nib?
pub fn uses_pressure(&self) -> bool {
self.modes().contains(&Variation::Pressure)
}
}
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
/// modes are appended, never renamed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Variation {
/// Always the value.
#[default]
Fixed,
/// A random value within the variation either side, new for each stroke.
Random,
/// From the value less the variation at the lightest pen pressure to the value plus the
/// variation at the heaviest.
Pressure,
}
impl Variation {
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
/// The name brush definitions use.
pub fn id(self) -> &'static str {
match self {
Variation::Fixed => "fixed",
Variation::Random => "random",
Variation::Pressure => "pressure",
}
}
pub fn label(self) -> &'static str {
match self {
Variation::Fixed => "Fixed",
Variation::Random => "Random",
Variation::Pressure => "Pressure",
}
}
pub fn parse(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == id)
}
}
@@ -345,7 +401,7 @@ fn tolerance(scale: f64) -> f64 {
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, &stroke.paint, &brush.name),
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, &brush.name),
+114 -1
View File
@@ -16,7 +16,7 @@ fn stroke(w: f64) -> StrokeLayer {
}
fn calli(angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
fn bounds(nodes: &[Node]) -> Rect {
@@ -334,3 +334,116 @@ fn expand_keeps_fill_and_adds_pieces() {
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art());
assert!(expand(&d, &plain).is_none());
}
/// A Calligraphic brush (round nib) whose size follows the pen pressure by `var` either way.
fn pressure_calli(size: f64, var: f64) -> Brush {
let c = Calligraphic {
size,
variation: [0.0, 0.0, var],
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
..Default::default()
};
Brush { name: "p".into(), kind: BrushKind::Calligraphic(c) }
}
/// The stroke's half-width (largest |y|) among outline points with x in `x0..x1`.
fn half_width(nodes: &[Node], x0: f64, x1: f64) -> f64 {
let mut pts = vec![];
for n in nodes {
all_points(n, &mut pts);
}
pts.iter().filter(|p| p.x >= x0 && p.x <= x1).map(|p| p.y.abs()).fold(0.0, f64::max)
}
fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) }
}
/// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to
/// size + variation at the heaviest, along the stroke where the pen pressed.
#[test]
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
/// Angle and roundness follow the pressure the same way, clamped to their ranges.
#[test]
fn pressure_varies_angle_and_roundness() {
let c = |modes| Calligraphic { angle: 0.0, roundness: 60.0, size: 10.0, variation: [90.0, 60.0, 0.0], modes: Some(modes) };
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
assert!(near(draw(c([Pressure, Fixed, Fixed]), 0.5), 6.0, 0.05));
assert!(near(draw(c([Pressure, Fixed, Fixed]), 1.0), 10.0, 0.05));
// Roundness 60 ± 60, kept within 1..100: light is a hairline, heavy is round.
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
}
/// Brushes saved before the variation modes keep their look: a variation means Random, none
/// means Fixed, and they are written back without modes.
#[test]
fn brushes_without_modes_vary_randomly_as_before() {
let old: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "size": 8, "variation": [0, 0, 3]})).unwrap();
let BrushKind::Calligraphic(c) = &old.kind else { panic!() };
assert_eq!(c.modes, None);
assert_eq!(c.modes(), [Variation::Fixed, Variation::Fixed, Variation::Random]);
assert!(!c.uses_pressure());
assert!(serde_json::to_value(&old).unwrap().get("modes").is_none(), "written back as it was");
// The same strokes as the brush with its modes spelled out.
let explicit = Brush {
kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed, Variation::Fixed, Variation::Random]), ..c.clone() }),
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
}
/// Junk definitions and pressure never panic and never draw non-finite art.
#[test]
fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
}
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{size} {var}");
}
}
}
+190
View File
@@ -293,6 +293,129 @@ impl WidthProfile {
}
}
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PressureProfile {
pub points: Vec<(f64, f64)>,
}
impl PressureProfile {
/// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to):
/// half way, so a Pressure brush draws its own value there.
pub const MID: f64 = 0.5;
/// The most samples a stroke keeps (after simplifying them).
pub const MAX_POINTS: usize = 512;
/// How far (in pressure) simplifying may move the recorded curve.
const TOLERANCE: f64 = 0.01;
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
pub fn at(&self, t: f64) -> f64 {
let p = &self.points;
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
let v = if t <= first.0 {
first.1
} else {
p.windows(2)
.find_map(|w| match w {
[a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)),
_ => None,
})
.unwrap_or(last.1)
};
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
/// A profile from (position, pressure) samples in order along the stroke: values are clamped
/// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to
/// [`Self::MAX_POINTS`]. `None` without a sample.
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
let pts: Vec<(f64, f64)> =
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
if pts.is_empty() {
return None;
}
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
// Positions only ever go forward.
let mut prev = 0.0;
for p in &mut pts {
p.0 = p.0.max(prev);
prev = p.0;
}
let keep = simplify_1d(&pts, Self::TOLERANCE);
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
let round = |v: f64| (v * 1e4).round() / 1e4;
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
}
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
if old.is_none() && added.is_none() {
return None;
}
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
let total = old_len + added_len;
if !total.is_finite() || total <= 1e-9 {
return old.or(added).cloned();
}
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
let o = old_len / total;
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
}
/// The pressure of the path reversed.
pub fn reversed(&self) -> Self {
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
}
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
pub fn sample() -> Self {
Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }
}
}
/// At most `max` of `pts`, evenly spread (the ends kept).
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
let n = pts.len();
if n <= max || max < 2 {
return pts;
}
(0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect()
}
/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through
/// them stays within `tol` (vertically) of every sample. The ends are always kept.
fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec<bool> {
let mut keep = vec![false; pts.len()];
let Some(last) = pts.len().checked_sub(1) else { return keep };
for i in [0, last] {
if let Some(k) = keep.get_mut(i) {
*k = true;
}
}
let mut stack = vec![(0, last)];
while let Some((i, j)) = stack.pop() {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue };
let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 };
let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1));
if let Some((k, d)) = far
&& d > tol
{
if let Some(f) = keep.get_mut(k) {
*f = true;
}
stack.push((i, k));
stack.push((k, j));
}
}
keep
}
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
/// preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -386,6 +509,11 @@ pub struct StrokeLayer {
/// How a gradient paint maps onto the stroke (within, along or across it).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub gradient_mode: StrokeGradientMode,
/// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive
/// brushes vary with it. It belongs to this path's shape, so appearances copied to other
/// art leave it behind ([`Appearance::without_pressure`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pressure: Option<PressureProfile>,
}
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
@@ -456,6 +584,7 @@ impl StrokeLayer {
arrow_align: ArrowAlign::Extend,
overprint: false,
gradient_mode: StrokeGradientMode::Within,
pressure: None,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
@@ -640,6 +769,15 @@ impl Appearance {
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
/// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
pub fn without_pressure(mut self) -> Self {
for it in &mut self.items {
if let AppearanceItem::Stroke(s) = it {
s.pressure = None;
}
}
self
}
pub fn fill_paint(&self) -> Paint {
self.fill().map(|f| f.paint.clone()).unwrap_or(Paint::None)
}
@@ -1103,6 +1241,58 @@ mod tests {
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
/// #852: pen pressure is kept simplified along the stroke and read back by position.
#[test]
fn pressure_profile_samples_simplify_and_interpolate() {
// A steady ramp keeps only its ends; a bump keeps its peak.
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
// A long noisy stroke stays bounded.
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Out-of-order points (a hand-edited file) still read within 0..1.
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
assert!((0.0..=1.0).contains(&odd.at(0.5)));
}
#[test]
fn pressure_profile_extends_and_reverses() {
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
// A side without pressure is half way.
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
assert_eq!(half.at(0.25), PressureProfile::MID);
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
assert_eq!(back, pressed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();
+12 -4
View File
@@ -60,8 +60,8 @@ pub(crate) mod skip {
pub use appearance::StrokeGradientMode;
pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
StrokeLayer, WidthProfile,
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,
StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
@@ -960,8 +960,12 @@ impl Document {
if c.is_layer() {
collect(c, rect, all, out);
} else if let Some(b) = c.geometric_bounds()
&& rect.contains(Point::new(b.x0, b.y0))
&& rect.contains(Point::new(b.x1, b.y1))
// Inside it, edges included: art flush with the right or bottom edge is on it too
// (`Rect::contains` leaves those out, #890).
&& b.x0 >= rect.x0
&& b.y0 >= rect.y0
&& b.x1 <= rect.x1
&& b.y1 <= rect.y1
{
out.push(c.id);
}
@@ -1174,6 +1178,10 @@ mod tests {
let wide = Rect::new(-1.0, -1.0, 40.0, 15.0);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside");
// Flush with the artboard's edges counts as inside: the right and bottom ones too (#890).
for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] {
assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}");
}
d.node_mut(a).unwrap().locked = true;
assert_eq!(d.art_on_artboard(wide, false), vec![b]);
// Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on.
+84 -1
View File
@@ -174,6 +174,61 @@ impl LiveShape {
spread(radii, *sides as usize);
spread(kinds, *sides as usize);
}
/// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of
/// `xf` (exact while its sides are equal). None for other shapes.
pub fn polygon_radius(&self) -> Option<f64> {
let LiveShape::Polygon { radius, xf, .. } = self else { return None };
Some(radius * xf.determinant().abs().sqrt())
}
/// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by
/// from straight up (the Rotate field's sense). None for other shapes.
pub fn polygon_angle(&self) -> Option<f64> {
let LiveShape::Polygon { .. } = self else { return None };
// No −0° or 360° from rounding: a polygon drawn upright reads 0°.
let a = (-self.angle_deg()).rem_euclid(360.0);
Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 })
}
/// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True
/// for other shapes.
pub fn polygon_sides_equal(&self) -> bool {
let LiveShape::Polygon { xf, .. } = self else { return true };
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
(sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy
}
/// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]).
pub fn set_polygon_radius(&mut self, r: f64) {
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let k = xf.determinant().abs().sqrt();
if k > 1e-12 && r.is_finite() {
*radius = r.max(0.0) / k;
}
}
/// The document radius that makes a polygon's sides `side` long (with its sides equal).
pub fn polygon_radius_for_side(&self, side: f64) -> Option<f64> {
let LiveShape::Polygon { sides, .. } = self else { return None };
Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin()))
}
/// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]).
pub fn set_polygon_angle(&mut self, angle: f64) {
let Some(now) = self.polygon_angle() else { return };
if let LiveShape::Polygon { xf, .. } = self
&& angle.is_finite()
{
let centre = *xf * Point::ZERO;
*xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf;
}
}
/// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from
/// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`].
pub fn make_sides_equal(&mut self) {
let Some(r) = self.polygon_radius() else { return };
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let [a, b, _, _, e, f] = xf.as_coeffs();
let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY };
*xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip;
*radius = r;
}
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
@@ -253,7 +308,7 @@ impl LiveShape {
radii.iter_mut().for_each(|r| *r /= k);
true
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
/// Rotation angle of the live shape in degrees, clockwise on the page (y points down).
pub fn angle_deg(&self) -> f64 {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
@@ -1357,6 +1412,34 @@ mod tests {
assert_eq!(path.label(), "Path");
}
#[test]
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] };
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
p.set_polygon_angle(45.0);
assert!(close(p.polygon_angle().unwrap(), 45.0));
let centre = p.to_path().bounds().unwrap().center();
assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}");
p.set_polygon_radius(30.0);
assert!(close(p.polygon_radius().unwrap(), 30.0));
// A hexagon's side is as long as its radius.
assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0));
// Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius.
p.transform(Affine::scale_non_uniform(2.0, 1.0));
assert!(!p.polygon_sides_equal());
let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap());
p.make_sides_equal();
assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a));
let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") };
assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0));
// Other shapes have none of it.
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None));
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
+1 -1
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change}, newName?} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size]; pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest)}, newName?} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
+139 -11
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…], fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
has_doc,
freehand
),
@@ -36,6 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
curvature
),
cmd!(
"path.curvatureEdit",
"Curvature",
[],
None,
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments; insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
has_doc,
curvature_edit
),
cmd!(
"path.removeAnchor",
"Delete Anchor Point",
@@ -142,15 +151,21 @@ pub fn specs() -> Vec<CommandSpec> {
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
}
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
/// `[{x,y,pressure}…]` (0..1).
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
let pts: Vec<Point> = a
let pts: Vec<(Point, Option<f64>)> = a
.iter()
.filter_map(|v| match v {
Value::Array(xy) => Some(Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?)),
Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)),
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
_ => None,
})
.filter(|p| p.x.is_finite() && p.y.is_finite())
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
.collect();
if pts.is_empty() {
return Err(bad(cmd, "need at least one point"));
@@ -158,6 +173,31 @@ fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(pts)
}
/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of
/// its samples has one; samples without one count as full pressure, as a pen event without one.
fn stroke_pressure(samples: &[(Point, Option<f64>)]) -> Option<PressureProfile> {
if samples.iter().all(|(_, p)| p.is_none()) {
return None;
}
let mut at = 0.0;
let mut prev: Option<Point> = None;
let along: Vec<(f64, f64)> = samples
.iter()
.map(|(pt, p)| {
at += prev.map_or(0.0, |q| q.distance(*pt));
prev = Some(*pt);
(at, p.unwrap_or(1.0))
})
.collect();
PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p)))
}
/// The length of a subpath.
fn subpath_len(sp: &SubPath) -> f64 {
use kurbo::ParamCurveArclen;
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
}
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
@@ -492,7 +532,9 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let mut pts = points_param(p, "points", C)?;
let samples = samples_param(p, "points", C)?;
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
@@ -502,6 +544,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
let mut sp = pd.subpaths[si].clone();
let old_len = subpath_len(&sp);
if at_start {
sp.reverse();
}
@@ -538,12 +581,24 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
// The path's pressure (in the direction it is continued in) followed by the stroke's.
let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());
let joined = (old_pressure.is_some() || pressure.is_some()).then(|| {
let old = old_pressure.map(|p| if at_start { p.reversed() } else { p });
let total = subpath_len(&sp);
PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len)
});
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
let path = path_mut(d, id)?;
if at_start && !sp.closed {
let reverse = at_start && !sp.closed;
if reverse {
sp.reverse();
}
path.subpaths[si] = sp;
path_mut(d, id)?.subpaths[si] = sp;
if let Some(joined) = joined
&& let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut())
{
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
}
sel.set([id]);
Ok(())
})?;
@@ -554,7 +609,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let sp = fit_freehand(&pts, tol, closed);
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
let look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
let mut look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
if let Some(st) = look.appearance.stroke_mut() {
st.pressure = pressure;
}
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
}
@@ -593,6 +651,76 @@ fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None)
}
/// An edit `path.curvatureEdit` makes.
enum CurvatureOp {
Move(usize, Point),
Insert(usize, f64, Option<Point>),
Extend(Point),
Close,
}
fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
_ => return Err(bad(C, "x and y must be finite numbers")),
};
let to = || at.ok_or_else(|| bad(C, "missing x, y"));
let start = match str_param(p, "end") {
None | Some("end") => false,
Some("start") => true,
Some(e) => return Err(bad(C, format!("unknown end {e:?}"))),
};
let how = match str_param(p, "from") {
None => EndCurve::Keep,
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
};
let op = match str_param(p, "op") {
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
Some("extend") => CurvatureOp::Extend(to()?),
Some("close") => CurvatureOp::Close,
Some(o) => return Err(bad(C, format!("unknown op {o:?}"))),
None => return Err(bad(C, "missing op")),
};
let anchor = s.edit("Curvature", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if matches!(op, CurvatureOp::Extend(_) | CurvatureOp::Close) && sp.closed {
return Err(EngineError::Other("path is already closed".into()));
}
let ai = match op {
CurvatureOp::Move(ai, q) => curvature_move(sp, ai, q).then_some(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?,
CurvatureOp::Insert(seg, t, q) => {
let ai = curvature_insert(sp, seg, t).ok_or_else(|| EngineError::Other("no such segment".into()))?;
if let Some(q) = q {
curvature_move(sp, ai, q);
}
ai
}
CurvatureOp::Extend(q) => {
curvature_extend(sp, start, q, how);
if start { 0 } else { sp.anchors.len() - 1 }
}
CurvatureOp::Close => {
if sp.anchors.len() < 3 {
return Err(EngineError::Other("closing needs three anchors".into()));
}
curvature_close(sp, start, how);
if start { sp.anchors.len() - 1 } else { 0 }
}
};
sel.set([id]);
sel.anchors.insert(id, [(si, ai)].into());
Ok(ai)
})?;
Ok(json!({ "anchor": anchor }))
}
// ---------- anchor tools ----------
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
+28 -8
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@@ -1095,24 +1095,44 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let angle = |k: &str| -> Result<Option<f64>> {
// A number that must be `what` (None when absent).
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|a| a.is_finite())
.map(|a| Some(a.rem_euclid(360.0)))
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
.filter(|x| x.is_finite() && ok(*x))
.map(Some)
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))),
}
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
if let (Some(l @ LiveShape::Polygon { .. }), Some(n)) = (live.as_mut(), sides) {
l.set_sides(n);
if let Some(l @ LiveShape::Polygon { .. }) = live.as_mut()
&& (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal)
{
if let Some(n) = sides {
l.set_sides(n);
}
if equal {
l.make_sides_equal();
}
if let Some(a) = polygon_angle {
l.set_polygon_angle(a);
}
// A side length sets the radius that gives it (with the new side count).
if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) {
l.set_polygon_radius(r);
}
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
+2 -2
View File
@@ -215,9 +215,9 @@ fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
/// they're on).
/// they're on). The pen pressure of strokes stays with their own path.
pub(crate) fn captured(n: &Node) -> Appearance {
let mut ap = n.appearance.clone();
let mut ap = n.appearance.clone().without_pressure();
if !ap.items.is_empty() {
return unitized(ap, n.geometric_bounds());
}
+2 -1
View File
@@ -582,7 +582,8 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result<WebRender,
let doc = doc.without_edit_modes();
let (region, scale) = region(&doc, s)?;
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 4]);
let opts = vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, ..Default::default() };
let opts =
vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, precise: true, ..Default::default() };
let img = vectorcraft_render::Renderer::new().render_region_with(&doc, region, scale, &opts);
Ok(WebRender { rgba: img.to_straight(), width: img.width, height: img.height, region, scale })
}
+105 -1
View File
@@ -1,7 +1,7 @@
//! Brushes and symbols: commands, tools, rendering and persistence.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -193,6 +193,110 @@ fn paintbrush_tool_uses_the_current_brush() {
assert!(s.execute("brush.setCurrent", &json!({"name": "Nope"})).is_err());
}
/// A Size: Pressure variant of "3 pt. Round" (3 ± 3 pt).
fn pressure_round(s: &mut Session) {
let params = json!({"variation": [0, 0, 3], "modes": ["fixed", "fixed", "pressure"]});
s.execute("brush.options", &json!({"name": "3 pt. Round", "params": params})).unwrap();
}
fn pressure_of(s: &Session, id: NodeId) -> Option<vectorcraft_doc::PressureProfile> {
node(s, id).appearance.stroke().and_then(|st| st.pressure.clone())
}
/// #852: the Paintbrush records the pen pressure on the stroke, the brush draws it, and it lasts
/// through saving and every export; a mouse stroke records none.
#[test]
fn paintbrush_records_pen_pressure_on_the_stroke() {
let mut s = session();
pressure_round(&mut s);
s.execute("brush.setCurrent", &json!({"name": "3 pt. Round"})).unwrap();
let v = ViewInfo::default();
s.select_tool("paintbrush", v).unwrap();
let strokes = |s: &mut Session, y: f64, pressure: [f32; 4]| {
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Drag, PointerKind::Up];
for ((k, x), p) in kinds.into_iter().zip([20.0, 120.0, 220.0, 320.0]).zip(pressure) {
s.pointer(&PointerEvent { pressure: p, ..PointerEvent::new(k, x, y) }, v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
// Deselected, so the next stroke starts a path of its own.
select(s, &[]);
id
};
let pen = strokes(&mut s, 60.0, [0.0, 0.4, 0.8, 1.0]);
let p = pressure_of(&s, pen).expect("pressure recorded");
assert!(p.at(0.0) < 0.05 && p.at(1.0) > 0.95 && (p.at(0.5) - 0.6).abs() < 0.05, "{p:?}");
let mouse = strokes(&mut s, 200.0, [1.0; 4]);
assert_eq!(pressure_of(&s, mouse), None, "a mouse presses fully: nothing to record");
// Drawn: thin where the pen pressed lightly, 6 pt where it pressed fully.
let doc: Document = (*s.doc().unwrap().doc).clone();
let art = |d: &Document, id: NodeId| vectorcraft_brush::node_pieces(d, d.node(id).unwrap()).unwrap();
let heavy_end = vectorcraft_brush::pieces_bounds(&art(&doc, pen)).unwrap();
assert!((heavy_end.height() - 6.0).abs() < 0.3, "{heavy_end:?}");
let even = vectorcraft_brush::pieces_bounds(&art(&doc, mouse)).unwrap();
assert!((even.height() - 3.0).abs() < 0.1, "no pressure draws the size itself: {even:?}");
// Saved and reopened as it was.
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(pen).unwrap().appearance.stroke().unwrap().pressure, Some(p));
// SVG and PDF draw the brush through the same stroke code: without the pressure they differ.
let mut flat = doc.clone();
flat.node_mut(pen).unwrap().appearance.stroke_mut().unwrap().pressure = None;
assert_ne!(vectorcraft_svg::export(&doc, &Default::default()), vectorcraft_svg::export(&flat, &Default::default()));
let pdf = |d: &Document| {
vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap()
};
assert_ne!(pdf(&doc), pdf(&flat));
}
/// Continuing a pressure stroke keeps its pressure where it was and adds the new stroke's after it.
#[test]
fn continuing_a_stroke_joins_its_pressure() {
let mut s = session();
let r = s.execute("path.freehand", &json!({"points": [[0, 50, 0], [100, 50, 1]], "style": "brush"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("path.freehand", &json!({"points": [[100, 50, 1], [200, 50, 0]], "style": "brush", "extend": {"id": id.0, "end": "end"}})).unwrap();
let p = pressure_of(&s, id).unwrap();
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05 && p.at(1.0) < 0.05, "{p:?}");
// Continued at its start: the new stroke comes first, running backwards from where it joined.
let r = s.execute("path.freehand", &json!({"points": [[0, 150], [100, 150]], "style": "brush"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("path.freehand", &json!({"points": [[0, 150, 1], [-100, 150, 0]], "style": "brush", "extend": {"id": id.0, "end": "start"}})).unwrap();
let p = pressure_of(&s, id).unwrap();
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05, "{p:?}");
assert_eq!(p.at(1.0), vectorcraft_doc::PressureProfile::MID, "the old part had no pressure");
}
/// Pen pressure belongs to its path: new art, graphic styles and Brush Options' edits leave it.
#[test]
fn pressure_stays_with_its_own_path() {
let mut s = session();
s.execute("appearance.setNewArtBasic", &json!({"on": false})).unwrap();
let r = s.execute("brush.freehand", &json!({"points": [[0, 50, 0.2], [100, 50, 0.9]], "style": "brush", "brush": "3 pt. Round"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert!(pressure_of(&s, id).is_some());
select(&mut s, &[id]);
s.execute("graphicStyle.new", &json!({"name": "Inked"})).unwrap();
let style = s.doc().unwrap().doc.graphic_style("Inked").unwrap().appearance.clone();
assert!(style.stroke().is_some_and(|st| st.pressure.is_none()));
let other = rect(&mut s, 10.0, 100.0, 50.0, 50.0);
assert_eq!(pressure_of(&s, other), None, "new art takes the look, not the pressure");
pressure_round(&mut s);
assert!(pressure_of(&s, id).is_some(), "editing the brush keeps the strokes' pressure");
}
/// Brush Options sets the variation modes; bad modes are refused, never a panic.
#[test]
fn brush_options_set_variation_modes() {
let mut s = session();
pressure_round(&mut s);
let b = s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap();
assert_eq!(b["modes"], json!(["fixed", "fixed", "pressure"]));
assert_eq!(b["variation"], json!([0.0, 0.0, 3.0]));
for bad in [json!({"modes": ["fixed", "fixed", "tilt"]}), json!({"modes": "pressure"}), json!({"variation": "x"}), json!({"modes": [1, 2, 3]})] {
assert!(s.execute("brush.options", &json!({"name": "3 pt. Round", "params": bad})).is_err(), "{bad}");
}
assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing");
}
// ---------- symbols ----------
fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) {
+62
View File
@@ -197,6 +197,68 @@ fn curvature_click_first_point_closes() {
assert_eq!(p.anchor_count(), 3);
}
/// The Curvature tool edits a path another tool drew (#798), one undo step per edit, keeping its
/// shape except where edited.
#[test]
fn curvature_edits_any_selected_path() {
let mut s = session();
let v = view();
let anchors = json!([
{"x": 100, "y": 300},
{"x": 200, "y": 200, "in": [160, 210], "out": [260, 185]},
{"x": 350, "y": 280, "in": [320, 230], "out": [380, 330]},
{"x": 450, "y": 350, "in": [420, 360], "out": [480, 300]},
{"x": 550, "y": 260, "in": [520, 290]}
]);
let id = NodeId(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("curvature", v).unwrap();
let before = path(&s, id).subpaths[0].clone();
// Drag a point: only its two segments change.
assert_eq!(gesture(&mut s, &[(350.0, 280.0), (360.0, 320.0), (370.0, 340.0)], Mods::default()), 1);
let sp = path(&s, id).subpaths[0].clone();
assert_eq!(sp.anchors[2].p, Point::new(370.0, 340.0));
assert_eq!((sp.segment(0), sp.segment(3)), (before.segment(0), before.segment(3)));
assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(1), "the point is current");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0], before);
// Alt-click toggles it, Delete removes the current point: one step each.
let (alt, steps) = (Mods { alt: true, ..Default::default() }, undo_steps(&s));
assert_eq!(gesture(&mut s, &[(200.0, 200.0)], alt), 1);
assert!(!path(&s, id).subpaths[0].anchors[1].has_out());
s.tool_key(ToolKey::Delete, Mods::default(), v).unwrap();
assert_eq!(path(&s, id).anchor_count(), 4);
assert_eq!(undo_steps(&s), steps + 2);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0], before);
// A press on a segment adds a point, the drag moving it: one step.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let on = kurbo::ParamCurve::eval(&before.segment(3), 0.5);
assert_eq!(gesture(&mut s, &[(on.x, on.y), (on.x, on.y + 20.0), (on.x, on.y + 30.0)], Mods::default()), 1);
let sp = path(&s, id).subpaths[0].clone();
assert_eq!(sp.anchors.len(), 6);
assert_eq!(sp.anchors[4].p, Point::new(on.x, on.y + 30.0));
assert_eq!((sp.segment(0), sp.segment(1), sp.segment(2)), (before.segment(0), before.segment(1), before.segment(2)));
s.execute("edit.undo", &json!({})).unwrap();
// A click on an end, then two more: the path goes on, its old segments unchanged.
let steps = undo_steps(&s);
for (x, y) in [(550.0, 260.0), (650.0, 300.0), (720.0, 220.0)] {
gesture(&mut s, &[(x, y)], Mods::default());
}
assert_eq!(undo_steps(&s), steps + 2, "selecting the end is no step");
let sp = path(&s, id).subpaths[0].clone();
assert_eq!(sp.anchors.len(), 7);
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
assert_eq!(sp.anchors[6].p, Point::new(720.0, 220.0));
// The other end closes it; Esc and a click away start a new path.
gesture(&mut s, &[(100.0, 300.0)], Mods::default());
assert!(path(&s, id).is_closed());
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
gesture(&mut s, &[(700.0, 550.0)], Mods::default());
assert_eq!(paths(&s).len(), 2);
}
#[test]
fn add_and_delete_anchor_tools() {
let mut s = session();
+39
View File
@@ -307,3 +307,42 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
/// #812: a polygon's Polygon Properties: its angle, radius and side length, each one undo step;
/// Make Sides Equal after an uneven scale; bad values are errors.
#[test]
fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap());
let live = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(l @ LiveShape::Polygon { .. }), path, .. } => (l.clone(), path.bounds().unwrap()),
k => panic!("{k:?}"),
};
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
run(&mut s, json!({"polygonAngle": 90}));
let (l, b) = live(&s);
assert!(close(l.polygon_angle().unwrap(), 90.0), "{l:?}");
// Its first vertex, straight up before, points left now: the hexagon is 100 wide, 86.6 tall.
assert!(close(b.width(), 100.0) && close(b.height(), 86.602_540_378) && close(b.center().x, 200.0), "{b:?}");
run(&mut s, json!({"polygonRadius": 80}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 80.0));
// A hexagon's side is as long as its radius; with 4 sides the radius is side / √2.
run(&mut s, json!({"sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0));
run(&mut s, json!({"sides": 4, "sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0 / 2f64.sqrt()));
s.execute("edit.undo", &json!({})).unwrap();
let LiveShape::Polygon { sides, .. } = live(&s).0 else { panic!("polygon") };
assert_eq!(sides, 6, "one undo step");
// Scaled unevenly its sides differ; Make Sides Equal makes them equal again, in place.
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let (l, b) = live(&s);
assert!(!l.polygon_sides_equal() && l.polygon_angle().is_some());
run(&mut s, json!({"makeSidesEqual": true}));
let (l, after) = live(&s);
assert!(l.polygon_sides_equal() && close(after.center().x, b.center().x) && close(after.center().y, b.center().y), "{b:?} → {after:?}");
for bad in [json!({"polygonRadius": 0}), json!({"sideLength": -3}), json!({"polygonRadius": "big"}), json!({"polygonAngle": "left"})] {
assert!(s.execute("object.setLiveShape", &bad).is_err(), "{bad}");
}
}
+49
View File
@@ -214,7 +214,25 @@ fn structured_junk() {
("path.freehand", json!({"points": [[0, 0]]})),
("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})),
("path.freehand", json!({"points": [[0, 0, -5], [10, 0, 1e308], [20, 0, "x"], [30, 0, null], [40, 0, 0.5]], "style": "brush"})),
("path.freehand", json!({"points": [[0, 0, 0.2], [0, 0, 0.9], [0, 0, 0.1]]})),
(
"path.freehand",
json!({"points": [{"x": 0, "y": 0, "pressure": 2}, {"x": 1e308, "y": 0, "pressure": -1}], "extend": {"id": 2, "end": "start"}}),
),
("brush.freehand", json!({"points": [[0, 0, 0], [50, 50, 1]], "brush": "3 pt. Round"})),
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "random", "nope"]}})),
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": "pressure", "variation": [1e308, -1e308, 1e308]}})),
(
"brush.options",
json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "pressure", "pressure"], "variation": [1e308, 1e308, 1e308], "size": 1e308, "angle": -1e308}}),
),
("path.curvature", json!({"points": [{"x": 0, "y": 0}]})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 1000, "x": 0, "y": 0})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 0, "x": 1e308, "y": -1e308})),
("path.curvatureEdit", json!({"id": 2, "subpath": 7, "op": "extend", "x": 1e308, "y": -1e308, "from": "smooth"})),
("path.curvatureEdit", json!({"id": 2, "op": "insert", "segment": 99, "t": -5, "x": 1})),
("path.curvatureEdit", json!({"id": 2, "op": "close", "end": "start", "from": "corner"})),
("path.knife", json!({"points": [[0, 0], [1e9, 1e9]]})),
("path.eraseRegion", json!({"points": [[100, 80]], "size": 0})),
("path.eraseRegion", json!({"points": [[100, 80], [101, 80]], "size": 1e9})),
@@ -379,6 +397,37 @@ fn area_options_fit_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// A live polygon's Polygon Properties with junk values (the fixtures have no polygon), drawn
/// plain, scaled unevenly and flattened to nothing: never a panic, and the document stays sound.
#[test]
fn polygon_properties_junk() {
let mut cases: Vec<Value> = vec![json!({"makeSidesEqual": true}), json!({"sides": u64::MAX, "sideLength": 1e-300, "makeSidesEqual": true})];
for key in ["polygonRadius", "sideLength", "polygonAngle"] {
for v in junk_values() {
cases.push(json!({ key: v }));
}
}
let mut failures = vec![];
for scale in [None, Some((300.0, 20.0)), Some((1e-300, 100.0))] {
for p in &cases {
let mut s = Fixture::Multi.session();
let Ok(_) = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 40, "sides": 7, "rotation": 10})) else { continue };
if let Some((sx, sy)) = scale {
let _ = s.execute("object.scale", &json!({"sx": sx, "sy": sy}));
}
match catch_quiet(|| s.execute("object.setLiveShape", p)) {
Err(m) => failures.push(format!("PANIC object.setLiveShape {p} [{scale:?}]: {m}")),
Ok(_) => {
if let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("object.setLiveShape {p} [{scale:?}]: {e}"));
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// After a successful fuzzed call, the document still round-trips and exports.
#[test]
fn fuzzed_calls_keep_documents_serializable() {
+3 -1
View File
@@ -87,7 +87,7 @@ impl RasterExportOptions {
}
/// What the renderer draws for `format`: template layers and guides left out, over the background
/// (white for a JPEG or a flat PSD without one).
/// (white for a JPEG or a flat PSD without one), composited in floating point.
pub fn render_options(&self, format: RasterFormat) -> RenderOptions {
let flat = format == RasterFormat::Jpeg || (format == RasterFormat::Psd && !self.psd.layers);
let background = self.background.or(flat.then_some([255; 3]));
@@ -95,6 +95,8 @@ impl RasterExportOptions {
background: background.map(|[r, g, b]| [r, g, b, 255]),
skip_templates: true,
anti_alias: self.anti_alias,
// Opaque art stays exactly opaque where translucent edges cross it (#787).
precise: true,
..Default::default()
}
}
+38
View File
@@ -110,6 +110,44 @@ fn backgrounds_are_opaque_or_transparent() {
assert_eq!(webp.get_pixel(0, 0).0, [0, 0, 0, 255]);
}
/// #787: translucent art over opaque art (or a background) stays exactly opaque, its anti-aliased
/// edges too, so the file is written as RGB.
#[test]
fn translucent_edges_over_opaque_art_stay_opaque() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let under = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut over =
Node::path(d.alloc_id(), shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
over.opacity = 0.5;
let only_over = {
let mut d = d.clone();
d.insert(Some(l), 0, over.clone()).unwrap();
d
};
d.insert(Some(l), 0, under).unwrap();
d.insert(Some(l), 1, over).unwrap();
// Over a black background at 72 ppi, and over an opaque rectangle at a scale that puts the
// edges on fractions of a pixel.
for (doc, o) in [
(&only_over, RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() }),
(&d, RasterExportOptions { ppi: 72.0 * 0.3515625, ..Default::default() }),
] {
let png = Renderer::new().export_region(doc, doc.artboards[0].rect, RasterFormat::Png, &o).unwrap();
let img = image::load_from_memory(&png).unwrap().to_rgba8();
let below: Vec<_> = img.pixels().filter(|p| p[3] < 255).collect();
assert!(below.is_empty(), "ppi {}: {} pixels below alpha 255, e.g. {:?}", o.ppi, below.len(), below.first());
assert_eq!(png[25], 2, "IHDR colour type: RGB");
}
// The inside is half white over black.
let img = export(&only_over, RasterFormat::Png, &RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() });
assert!(img.get_pixel(50, 50).0[..3].iter().all(|c| (127..=128).contains(c)), "{:?}", img.get_pixel(50, 50));
}
#[test]
fn interlaced_export_decodes_to_the_same_pixels() {
let d = circle_doc();
+2 -2
View File
@@ -137,7 +137,7 @@ impl Renderer {
fn flatten(&mut self, ctx: &mut RenderContext, r: Region) -> Arc<Pixmap> {
ctx.flush();
let mut all = Pixmap::new(ctx.width(), ctx.height());
ctx.render(&mut all, &mut self.resources);
ctx.render_with(&mut all, &mut self.resources, self.raster);
ctx.reset();
let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) {
None
@@ -171,7 +171,7 @@ impl Renderer {
(self.nested, self.backdrop, self.clip_paths) = outer;
ctx.flush();
let mut pm = Pixmap::new(r.w, r.h);
ctx.render(&mut pm, &mut self.resources);
ctx.render_with(&mut pm, &mut self.resources, self.raster);
pm
}
+13 -2
View File
@@ -110,6 +110,10 @@ pub struct RenderOptions {
/// Screen view in isolation mode: the isolated group or layer. Everything around it draws
/// dimmed ([`ISOLATION_DIM`]).
pub isolated: Option<NodeId>,
/// Composite in floating point (exports): translucent art over opaque art comes out exactly
/// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off,
/// the faster 8-bit pipeline (the canvas).
pub precise: bool,
}
/// The opacity of the art around an isolated group or layer.
@@ -181,6 +185,7 @@ impl Default for RenderOptions {
trace_views: false,
smooth_images: true,
isolated: None,
precise: false,
}
}
}
@@ -362,6 +367,9 @@ pub struct Renderer {
/// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing
/// it): drawing stops at [`MAX_INLINE_DEPTH`].
inline_depth: u32,
/// How the frame being drawn is rasterized ([`RenderOptions::precise`]): its offscreen groups,
/// masks and patterns too.
raster: vello_cpu::RasterizerSettings,
/// [`RenderOptions::isolated`]'s layers and groups, from the top layer down to it, for the
/// frame (`stamp`) and document they were found in.
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
@@ -453,6 +461,7 @@ impl Renderer {
adjusted: Default::default(),
dim_images: None,
inline_depth: 0,
raster: vello_cpu::RasterizerSettings::default(),
isolation: None,
isolation_settled: false,
}
@@ -532,6 +541,8 @@ impl Renderer {
};
// `reset` keeps the threshold of the previous render: set it every time.
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed };
self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() };
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(f.ink.fixed(bg));
@@ -560,7 +571,7 @@ impl Renderer {
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut self.resources);
ctx.render_with(&mut pm, &mut self.resources, self.raster);
if threads == 0 {
self.ctx_st = Some(ctx);
} else {
@@ -795,7 +806,7 @@ impl Renderer {
(self.knockout, self.nested, self.backdrop, self.clip_paths) = outer;
mctx.flush();
let mut pm = Pixmap::new(w, h);
mctx.render(&mut pm, &mut self.resources);
mctx.render_with(&mut pm, &mut self.resources, self.raster);
pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()
}
+1 -1
View File
@@ -88,7 +88,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut r.resources);
ctx.render_with(&mut pm, &mut r.resources, r.raster);
pm
}
+16 -10
View File
@@ -32,7 +32,7 @@ use vectorcraft_geom::{Anchor, PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::{HandleSnap, PointSnap, Targets};
use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
@@ -428,12 +428,12 @@ impl Tool for DirectSelectionTool {
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
}
let mut d = move_delta(start, p, ev.mods.shift);
// The grabbed anchor snaps and the others follow it; Shift keeps the angle instead.
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
self.guides.clear();
if !ev.mods.shift
&& let Some(snap) = &self.anchor_snap
{
let (q, guides) = snap.snap(cx, grab + d);
if let Some(snap) = &self.anchor_snap {
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
(d, self.guides) = (q - grab, guides);
}
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
@@ -1022,10 +1022,16 @@ mod tests {
// In line with B's own bottom-left corner, which stays put.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none);
assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()]));
// Shift keeps the move at 45° steps, unsnapped.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), Mods { shift: true, ..none });
let s = move_delta(Point::new(300.0, 300.0), Point::new(203.0, 199.0), true);
assert_eq!((v, labels), (json!({"dx": s.x, "dy": s.y}), vec![]));
// Shift keeps the move at 45° steps and still snaps along that line (#886): on the
// diagonal from B's corner, onto A's bottom-right corner…
let shift = Mods { shift: true, ..none };
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift);
let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9;
assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}");
// …and with nothing in reach, at the pointer's distance along its angle.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift);
let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true);
assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}");
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
let two = anchors_of(b, &[(0, 0), (0, 1)]);
let c = cx(&d, &two, &p);
+10 -1
View File
@@ -15,7 +15,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::CurvatureTool;
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
pub use family::FamilyTool;
pub use gesture::GestureTool;
@@ -94,6 +94,15 @@ fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
v
}
/// The selected paths the Pen and the Curvature tool edit (not guides, nor locked or hidden ones).
pub(crate) fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
}
/// Nearest anchor within `tol`: (path, subpath, anchor).
pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
anchor_in(cx, candidate_paths(cx), p, tol)
+685 -30
View File
@@ -5,28 +5,293 @@
//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path.
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool). Each point
//! placed or dragged snaps to Smart Guides ([`DrawSnap`]), as the Pen's anchors do.
//!
//! It edits any selected path too, whatever drew it, keeping its shape except where edited
//! (#798). A click on one of its points makes that the current point (direct-selected); a drag
//! moves it, re-curving only the two segments at it ([`curvature_move`]); Alt-click or
//! double-click toggles it between smooth and corner and Backspace/Delete removes it. A click on
//! a segment adds a smooth point there without changing the shape ([`curvature_insert`]), and a
//! drag moves the new point at once. While the current point is an end of an open path, clicks go
//! on from it ([`curvature_extend`]): the old segments keep their shape, the new ones curve
//! through the new points, and a click on the path's other end closes it. Each edit is one
//! `path.curvatureEdit`, `path.convertAnchor` or `path.removeAnchor` (one undo step).
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, Selection};
use vectorcraft_geom::Point;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2};
use super::{FEEDBACK, catmull_rom};
use super::{FEEDBACK, anchor_in, catmull_rom, editable_paths, remove_anchor, segment_in};
use crate::guides::{DrawSnap, Leave, Targets};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The command that edits a selected path.
const EDIT: &str = "path.curvatureEdit";
#[derive(Default)]
pub struct CurvatureTool {
pts: Vec<(Point, bool)>,
closed: bool,
id: Option<NodeId>,
/// Point being dragged: (index, began).
drag: Option<(usize, Point, bool)>,
/// Last touched point (Backspace removes it).
/// The point being dragged, the pointer at the press, and whether the drag began.
drag: Option<(Grab, Point, bool)>,
/// Last touched point of the path being drawn (Backspace removes it).
current: Option<usize>,
hover: Option<Point>,
/// The end the last click on a selected path placed.
placed: Option<Placed>,
snap: DrawSnap,
}
/// What a press grabbed for a drag.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grab {
/// A point of the path being drawn.
Drawn(usize),
/// An anchor of a selected path: (path, subpath, anchor).
Anchor(NodeId, usize, usize),
/// The anchor the press added on a segment of a selected path: (path, subpath, segment, t).
Inserted(NodeId, usize, usize, f64),
}
/// The end a click on a selected path placed, while it still is that end (its subpath has as many
/// anchors as then): going on from it, it curves through its neighbours, or stays a corner when it
/// was Alt-clicked. Any other end keeps its curve.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Placed {
id: NodeId,
si: usize,
start: bool,
anchors: usize,
corner: bool,
}
/// The current point of a selected path while it is an end of an open subpath: where clicks go on
/// from (its start with `start`), and the other end, which a click closes the subpath on.
#[derive(Clone, Copy)]
struct End {
id: NodeId,
si: usize,
start: bool,
anchors: usize,
p: Point,
other: Point,
}
impl End {
/// `end` for `path.curvatureEdit`.
fn name(&self) -> &'static str {
if self.start { "start" } else { "end" }
}
}
/// How the end a path goes on from bends into the new segment ([`curvature_extend`],
/// [`curvature_close`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EndCurve {
/// The segment it ends keeps its shape: the new handle goes on along its handle, or along the
/// curve where it has none, and with both it is smooth.
#[default]
Keep,
/// Smooth through its neighbours, as [`catmull_rom`] curves a point (a point the Curvature tool
/// placed: its segment re-curves).
Smooth,
/// A corner, without handles.
Corner,
}
impl EndCurve {
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"keep" => Self::Keep,
"smooth" => Self::Smooth,
"corner" => Self::Corner,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Self::Keep => "keep",
Self::Smooth => "smooth",
Self::Corner => "corner",
}
}
}
/// The anchors before and after anchor `i` of `sp` (round the start of a closed one).
fn neighbours(sp: &SubPath, i: usize) -> [Option<usize>; 2] {
let n = sp.anchors.len();
let wrap = sp.closed && n > 1;
let prev = if i > 0 { Some(i - 1) } else { wrap.then(|| n - 1) };
let next = if i + 1 < n { Some(i + 1) } else { wrap.then_some(0) };
[prev, next]
}
/// Move anchor `i` of `sp` to `q` as the Curvature tool drags a point: only the two segments at it
/// change. A smooth anchor turns along the line through its neighbours; the neighbours' handles on
/// those segments, and a corner's own handles, keep their directions, their lengths scaling with
/// their segment's chord, so the segments beyond don't move and a corner without handles keeps
/// none. False when there is no anchor `i`.
pub fn curvature_move(sp: &mut SubPath, i: usize, q: Point) -> bool {
let Some(&a) = sp.anchors.get(i) else { return false };
let mut own = [a.h_in - a.p, a.h_out - a.p];
let mut ends = [None; 2];
for (side, j) in neighbours(sp, i).into_iter().enumerate() {
let Some(b) = j.and_then(|j| sp.anchors.get_mut(j)) else { continue };
let old = a.p.distance(b.p);
let s = if old > 1e-9 { q.distance(b.p) / old } else { 1.0 };
let h = if side == 0 { &mut b.h_out } else { &mut b.h_in };
*h = b.p + (*h - b.p) * s;
own[side] *= s;
ends[side] = Some(b.p);
}
let mut m = Anchor { p: q, h_in: q + own[0], h_out: q + own[1], kind: a.kind };
if let (AnchorKind::Smooth, [Some(prev), Some(next)]) = (a.kind, ends) {
let d = next - prev;
let l = d.hypot();
if l > 1e-9 {
// A handle it lacks comes out a third of the way to that side's neighbour.
let len = |h: Vec2, to: Point| if h.hypot() > 1e-9 { h.hypot() } else { q.distance(to) / 3.0 };
m.h_in = q - d * (len(own[0], prev) / l);
m.h_out = q + d * (len(own[1], next) / l);
}
}
if let Some(x) = sp.anchors.get_mut(i) {
*x = m;
}
true
}
/// Add a smooth anchor at `t` on segment `seg` of `sp` without changing its shape (on a straight
/// segment its handles lie along it, so a drag curves it): the new anchor's index. None when there
/// is no such segment.
pub fn curvature_insert(sp: &mut SubPath, seg: usize, t: f64) -> Option<usize> {
if seg >= sp.segment_count() {
return None;
}
let line = sp.segment_is_line(seg);
let i = sp.insert_anchor(seg, if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.5 });
if line {
sp.smooth_anchor(i);
}
Some(i)
}
/// Go on from an end of the open subpath `sp` (its start with `start`) to a new end at `p`, which
/// has no handles; the old end bends into the new segment as `how` says.
pub fn curvature_extend(sp: &mut SubPath, start: bool, p: Point, how: EndCurve) {
if start {
sp.reverse();
}
bend_last(sp, p, how);
sp.anchors.push(Anchor::corner(p));
if start {
sp.reverse();
}
}
/// Close the open subpath `sp` from its end (its start with `start`): that end bends into the
/// closing segment as `how` says, and the other keeps its curve.
pub fn curvature_close(sp: &mut SubPath, start: bool, how: EndCurve) {
if start {
sp.reverse();
}
if let (Some(first), Some(last)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) {
bend_last(sp, first, how);
sp.reverse();
bend_last(sp, last, EndCurve::Keep);
sp.reverse();
sp.closed = true;
}
if start {
sp.reverse();
}
}
/// Bend the last anchor of `sp` into a new segment on to `to`, as `how` says.
fn bend_last(sp: &mut SubPath, to: Point, how: EndCurve) {
let prev = sp.anchors.len().checked_sub(2).and_then(|j| sp.anchors.get(j)).copied();
let Some(e) = sp.anchors.last_mut() else { return };
match how {
EndCurve::Corner => e.retract(),
EndCurve::Smooth => {
let t = prev.map_or(Vec2::ZERO, |b| (to - b.p) / 6.0);
if t.hypot() > 1e-9 {
*e = Anchor { p: e.p, h_in: e.p - t, h_out: e.p + t, kind: AnchorKind::Smooth };
} else {
e.retract();
}
}
EndCurve::Keep => {
// The way the curve comes in: from its handle, or else the segment's tangent there.
let back = if e.has_in() { Some(e.h_in) } else { prev.map(|b| if b.h_out.distance(e.p) > 1e-9 { b.h_out } else { b.p }) };
let d = back.map_or(Vec2::ZERO, |b| e.p - b);
let l = d.hypot();
e.h_out = if l > 1e-9 { e.p + d * (e.p.distance(to) / 3.0 / l) } else { e.p };
if e.has_in() && e.has_out() {
e.kind = AnchorKind::Smooth;
}
}
}
}
/// Subpath `si` of `id`, a selected path the tool edits.
fn subpath<'a>(cx: &ToolContext<'a>, id: NodeId, si: usize) -> Option<&'a SubPath> {
if !editable_paths(cx).any(|e| e == id) {
return None;
}
cx.doc.node(id)?.path_data()?.subpaths.get(si)
}
/// The current point of a selected path: its one direct-selected anchor (path, subpath, anchor).
fn current_point(cx: &ToolContext) -> Option<(NodeId, usize, usize)> {
let mut sets = cx.selection.anchors.iter().filter(|(_, set)| !set.is_empty());
let (&id, set) = sets.next()?;
if set.len() != 1 || sets.next().is_some() {
return None;
}
let &(si, ai) = set.first()?;
subpath(cx, id, si).filter(|sp| ai < sp.anchors.len()).map(|_| (id, si, ai))
}
/// The current point while it is an end of an open subpath.
fn current_end(cx: &ToolContext) -> Option<End> {
let (id, si, ai) = current_point(cx)?;
let sp = subpath(cx, id, si)?;
let n = sp.anchors.len();
let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p);
if sp.closed || (ai > 0 && ai + 1 < n) {
return None;
}
// A one-anchor subpath goes on from its end.
let start = ai == 0 && n > 1;
let (p, other) = if start { (first, last) } else { (last, first) };
Some(End { id, si, start, anchors: n, p, other })
}
/// Make anchor `(id, si, ai)` the current point.
fn select_point((id, si, ai): (NodeId, usize, usize)) -> Action {
Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))
}
/// Toggle anchor `(id, si, ai)` of a selected path between smooth and corner.
fn toggle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize)) -> Option<Action> {
let a = subpath(cx, id, si)?.anchors.get(ai)?;
let to = if a.kind == AnchorKind::Smooth { "corner" } else { "smooth" };
Some(Action::Exec("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": to})))
}
/// Snap targets for dragging anchors `anchors` of subpath `si` of `id` (the rest of the path
/// pulls).
fn hold_targets(snap: &mut DrawSnap, cx: &ToolContext, id: NodeId, si: usize, anchors: &[usize]) {
let sel = Selection { objects: vec![id], anchors: [(id, anchors.iter().map(|&ai| (si, ai)).collect())].into(), ..Selection::default() };
snap.hold(cx, || Targets::for_anchor_drag(cx.doc, &sel));
}
/// How near a press picks a point.
fn point_tol(cx: &ToolContext) -> f64 {
cx.tol(5.0).max(cx.point_tol())
}
impl CurvatureTool {
/// The path being drawn, if it is still the single selected object and matches our points.
fn active(&self, cx: &ToolContext) -> Option<NodeId> {
@@ -58,12 +323,70 @@ impl CurvatureTool {
self.id = None;
self.drag = None;
self.current = None;
self.placed = None;
self.snap.clear();
}
/// The segment to the next point leaves the last one.
fn leave(&self, cx: &ToolContext) -> Option<Leave> {
self.pts.last().map(|(p, _)| Leave::segment(cx, *p, false))
/// The path the next click goes on (left out of the snap targets, its anchors still pulling)
/// and the segment to it: the path being drawn, or the selected one whose end is current.
fn leave(&self, cx: &ToolContext) -> Option<(NodeId, Leave)> {
match self.active(cx) {
Some(id) => self.pts.last().map(|(p, _)| (id, Leave::segment(cx, *p, false))),
None => current_end(cx).map(|e| (e.id, Leave::segment(cx, e.p, false))),
}
}
/// How end `e` bends into the next segment: as [`Placed`] says for the end the last click
/// placed, else it keeps its curve.
fn how(&self, e: &End) -> EndCurve {
match self.placed {
Some(pl) if (pl.id, pl.si, pl.start, pl.anchors) == (e.id, e.si, e.start, e.anchors) => {
if pl.corner {
EndCurve::Corner
} else {
EndCurve::Smooth
}
}
_ => EndCurve::Keep,
}
}
/// A press on a selected path the tool isn't drawing: on the other end of the open path going
/// on (it closes), on a point, anywhere while an end is current (the path goes on), or on a
/// segment. None elsewhere: a new path starts.
fn press_selected(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
let (p, tol) = (ev.pos, point_tol(cx));
let end = current_end(cx);
if let Some(e) = end.filter(|e| e.anchors >= 3 && p.distance(e.other) <= tol) {
let how = self.how(&e);
self.placed = None;
let v = json!({"id": e.id.0, "subpath": e.si, "op": "close", "end": e.name(), "from": how.name()});
return Some(vec![Action::Exec(EDIT.into(), v)]);
}
if let Some(hit) = anchor_in(cx, editable_paths(cx), p, tol) {
let mut out = vec![select_point(hit)];
if ev.mods.alt {
out.extend(toggle(cx, hit));
return Some(out);
}
let (id, si, ai) = hit;
hold_targets(&mut self.snap, cx, id, si, &[ai]);
self.drag = Some((Grab::Anchor(id, si, ai), p, false));
return Some(out);
}
if let Some(e) = end {
let q = self.snap.press(cx, p, &[e.id], Some(&Leave::segment(cx, e.p, false)));
let how = self.how(&e);
self.placed = Some(Placed { id: e.id, si: e.si, start: e.start, anchors: e.anchors + 1, corner: ev.mods.alt });
let v = json!({"id": e.id.0, "subpath": e.si, "op": "extend", "end": e.name(), "x": q.x, "y": q.y, "from": how.name()});
return Some(vec![Action::Exec(EDIT.into(), v)]);
}
let (id, si, seg, t) = segment_in(cx, editable_paths(cx), p, tol)?;
let n = subpath(cx, id, si)?.anchors.len().max(1);
hold_targets(&mut self.snap, cx, id, si, &[seg, (seg + 1) % n]);
self.drag = Some((Grab::Inserted(id, si, seg, t), p, true));
let v = json!({"id": id.0, "subpath": si, "op": "insert", "segment": seg, "t": t});
Some(vec![Action::Begin("Curvature".into()), Action::Preview(EDIT.into(), v)])
}
}
@@ -82,8 +405,8 @@ impl Tool for CurvatureTool {
}
match ev.kind {
PointerKind::Move => {
let from = active.and_then(|_| self.leave(cx));
self.hover = Some(self.snap.hover(cx, p, active.as_slice(), from.as_ref()));
let (on, from) = self.leave(cx).unzip();
self.hover = Some(self.snap.hover(cx, p, on.as_slice(), from.as_ref()));
vec![]
}
PointerKind::Down => {
@@ -99,19 +422,21 @@ impl Tool for CurvatureTool {
return vec![Action::Exec("path.curvature".into(), self.params())];
}
// The point dragged and the curve through it move: the rest pull.
let sel = Selection { objects: vec![id], anchors: [(id, [(0, i)].into())].into(), ..Selection::default() };
self.snap.hold(cx, || Targets::for_anchor_drag(cx.doc, &sel));
self.drag = Some((i, p, false));
hold_targets(&mut self.snap, cx, id, 0, &[i]);
self.drag = Some((Grab::Drawn(i), p, false));
return vec![];
}
if !self.closed {
let from = self.leave(cx);
let from = self.leave(cx).map(|l| l.1);
let p = self.snap.press(cx, p, &[id], from.as_ref());
self.pts.push((p, ev.mods.alt));
self.current = Some(self.pts.len() - 1);
return vec![Action::Exec("path.curvature".into(), self.params())];
}
}
if let Some(out) = self.press_selected(cx, ev) {
return out;
}
// Start a new path.
self.reset();
let p = self.snap.press(cx, p, &[], None);
@@ -120,20 +445,30 @@ impl Tool for CurvatureTool {
vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())]
}
PointerKind::Drag => {
let Some((i, start, began)) = self.drag else { return vec![] };
let Some((grab, start, began)) = self.drag else { return vec![] };
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Curvature".into()));
self.drag = Some((i, start, true));
self.drag = Some((grab, start, true));
}
let p = self.snap.drag(cx, p, None);
if let Some(pt) = self.pts.get_mut(i) {
pt.0 = p;
}
out.push(Action::Preview("path.curvature".into(), self.params()));
out.push(match grab {
Grab::Drawn(i) => {
if let Some(pt) = self.pts.get_mut(i) {
pt.0 = p;
}
Action::Preview("path.curvature".into(), self.params())
}
Grab::Anchor(id, si, ai) => {
Action::Preview(EDIT.into(), json!({"id": id.0, "subpath": si, "op": "move", "anchor": ai, "x": p.x, "y": p.y}))
}
Grab::Inserted(id, si, seg, t) => {
Action::Preview(EDIT.into(), json!({"id": id.0, "subpath": si, "op": "insert", "segment": seg, "t": t, "x": p.x, "y": p.y}))
}
});
out
}
PointerKind::Up => {
@@ -145,7 +480,7 @@ impl Tool for CurvatureTool {
}
PointerKind::DoubleClick => {
if active.is_none() {
return vec![];
return anchor_in(cx, editable_paths(cx), p, point_tol(cx)).and_then(|hit| toggle(cx, hit)).into_iter().collect();
}
let Some(i) = self.hit_point(cx, p) else { return vec![] };
self.pts[i].1 = !self.pts[i].1;
@@ -158,14 +493,30 @@ impl Tool for CurvatureTool {
self.id = Some(cx.selection.objects[0]);
}
}
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Backspace | ToolKey::Delete)
&& self.drag.is_none()
&& if self.active(cx).is_some() { self.pts.len() > 1 } else { current_point(cx).is_some() }
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Escape | ToolKey::Enter => {
let dragging = self.drag.is_some_and(|d| d.2);
let drawing = self.active(cx).is_some();
self.reset();
if dragging { vec![Action::Cancel] } else { vec![] }
if dragging {
return vec![Action::Cancel];
}
// A selected path's current point goes: clicks no longer go on from it.
if drawing || current_point(cx).is_none() {
return vec![];
}
vec![Action::Exec("select.set".into(), json!({"ids": crate::json_ids(&cx.selection.objects)}))]
}
ToolKey::Backspace | ToolKey::Delete if self.active(cx).is_some() && self.pts.len() > 1 && self.drag.is_none() => {
ToolKey::Backspace | ToolKey::Delete if self.claims_key(cx, key) => {
if self.active(cx).is_none() {
return current_point(cx).map(remove_anchor).into_iter().collect();
}
let i = self.current.unwrap_or(self.pts.len() - 1).min(self.pts.len() - 1);
self.pts.remove(i);
if self.pts.len() < 3 {
@@ -184,19 +535,34 @@ impl Tool for CurvatureTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
let band = cx.curvature_rubber_band && self.drag.is_none();
let mut bp = BezPath::new();
if self.active(cx).is_none() {
// The curve on from the current end of a selected path: its last segment and the next.
if band
&& let (Some(e), Some(h)) = (current_end(cx), self.hover)
&& let Some(sp) = subpath(cx, e.id, e.si)
{
let n = sp.anchors.len();
let near = if e.start {
sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect()
} else {
sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect()
};
let mut tail = SubPath::new(near, false);
curvature_extend(&mut tail, false, h, self.how(&e));
tail.to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
}
return o;
}
if cx.curvature_rubber_band
if band
&& !self.closed
&& self.drag.is_none()
&& let Some(h) = self.hover
{
let mut pts = self.pts.clone();
pts.push((h, false));
let sp = catmull_rom(&pts, false);
let mut bp = vectorcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
catmull_rom(&pts, false).to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
}
for (i, (p, _)) in self.pts.iter().enumerate() {
@@ -204,7 +570,33 @@ impl Tool for CurvatureTool {
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
if self.active(cx).is_some() {
return match self.hit_point(cx, p) {
Some(0) if !self.closed && self.pts.len() >= 3 => Cursor::PenClose,
Some(_) if m.alt => Cursor::PenConvert,
Some(_) => Cursor::Move,
None => Cursor::Pen,
};
}
let tol = point_tol(cx);
let end = current_end(cx);
if end.is_some_and(|e| e.anchors >= 3 && p.distance(e.other) <= tol) {
return Cursor::PenClose;
}
if let Some((id, si, ai)) = anchor_in(cx, editable_paths(cx), p, tol) {
let open_end = subpath(cx, id, si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()));
return if m.alt {
Cursor::PenConvert
} else if open_end {
Cursor::PenContinue
} else {
Cursor::Move
};
}
if end.is_none() && segment_in(cx, editable_paths(cx), p, tol).is_some() {
return Cursor::PenAdd;
}
Cursor::Pen
}
}
@@ -213,7 +605,8 @@ impl Tool for CurvatureTool {
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
use vectorcraft_doc::{Appearance, Document, Node, Selection};
use vectorcraft_geom::{ParamCurve, PathData};
#[test]
fn first_click_creates_then_notify_tracks_path() {
@@ -252,4 +645,266 @@ mod tests {
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.0, 99.0));
assert!(t.overlays(&cx).is_empty());
}
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
/// An open pen-style path: a corner start, two smooth anchors with uneven handles, a cusp and
/// an end with only an incoming handle.
fn wave() -> SubPath {
SubPath::new(
vec![
Anchor::corner(pt(0.0, 100.0)),
Anchor::with_handles(pt(100.0, 0.0), pt(60.0, 10.0), pt(160.0, -15.0)),
Anchor::with_handles(pt(250.0, 80.0), pt(220.0, 30.0), pt(280.0, 130.0)),
Anchor::with_handles(pt(350.0, 150.0), pt(320.0, 160.0), pt(380.0, 100.0)),
Anchor::with_handles(pt(450.0, 60.0), pt(420.0, 90.0), pt(450.0, 60.0)),
],
false,
)
}
/// Every point of `a` lies on `b` (within 1e-6), sampled.
fn lies_on(a: &SubPath, b: &SubPath) -> bool {
let b = PathData::single(b.clone());
(0..a.segment_count()).all(|i| (0..=20).all(|k| b.nearest(a.segment(i).eval(k as f64 / 20.0)).is_some_and(|n| n.4 < 1e-6)))
}
fn collinear(a: Vec2, b: Vec2) -> bool {
a.cross(b).abs() < 1e-6 * a.hypot().max(1.0) * b.hypot().max(1.0)
}
#[test]
fn moving_a_point_recurves_only_its_two_segments() {
let before = wave();
let mut sp = before.clone();
assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth);
assert!(curvature_move(&mut sp, 2, pt(260.0, 140.0)));
assert_eq!(sp.anchors[2].p, pt(260.0, 140.0));
// The segments beyond its neighbours are bit-identical.
assert_eq!(sp.segment(0), before.segment(0));
assert_eq!(sp.segment(3), before.segment(3));
// The neighbours keep their handle directions on its segments, and their other handles.
assert!(collinear(sp.anchors[1].h_out - sp.anchors[1].p, before.anchors[1].h_out - before.anchors[1].p));
assert!(collinear(sp.anchors[3].h_in - sp.anchors[3].p, before.anchors[3].h_in - before.anchors[3].p));
assert_eq!((sp.anchors[1].h_in, sp.anchors[3].h_out), (before.anchors[1].h_in, before.anchors[3].h_out));
// The moved smooth point turns along the line through its neighbours.
let a = sp.anchors[2];
assert_eq!(a.kind, AnchorKind::Smooth);
assert!(collinear(a.h_out - a.p, sp.anchors[3].p - sp.anchors[1].p) && collinear(a.h_in - a.p, a.h_out - a.p));
assert!((a.h_out - a.p).dot(sp.anchors[3].p - sp.anchors[1].p) > 0.0);
// A cusp keeps its handle directions; a moved end changes its one segment only.
assert_eq!(sp.anchors[3].kind, AnchorKind::Corner);
assert!(curvature_move(&mut sp, 3, pt(360.0, 170.0)));
assert!(collinear(sp.anchors[3].h_out - sp.anchors[3].p, before.anchors[3].h_out - before.anchors[3].p));
let mut end = before.clone();
assert!(curvature_move(&mut end, 4, pt(470.0, 20.0)));
assert_eq!(end.segment(2), before.segment(2));
assert!(!curvature_move(&mut end, 5, pt(0.0, 0.0)));
}
#[test]
fn moving_a_corner_without_handles_keeps_its_sides_straight() {
let (d, id) = doc_with_rect();
let mut sp = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
assert!(curvature_move(&mut sp, 0, pt(80.0, 70.0)));
assert!((0..sp.segment_count()).all(|i| sp.segment_is_line(i)));
assert_eq!(sp.anchors[0], Anchor::corner(pt(80.0, 70.0)));
}
#[test]
fn going_on_from_an_end_keeps_the_old_segments() {
let before = wave();
let mut sp = before.clone();
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep);
assert_eq!(sp.anchors.len(), 6);
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
// The old end turns smooth, going on along its incoming handle, a third of the chord out.
let e = sp.anchors[4];
assert_eq!(e.kind, AnchorKind::Smooth);
assert!(collinear(e.h_out - e.p, e.p - e.h_in) && (e.h_out - e.p).dot(e.p - e.h_in) > 0.0);
assert!((e.h_out.distance(e.p) - e.p.distance(pt(550.0, 120.0)) / 3.0).abs() < 1e-9);
assert_eq!(sp.anchors[5], Anchor::corner(pt(550.0, 120.0)));
// The point placed then curves through its neighbours as a drawn path's points do.
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth);
let drawn = catmull_rom(&[(pt(450.0, 60.0), false), (pt(550.0, 120.0), false), (pt(650.0, 60.0), false)], false);
assert_eq!((sp.anchors[5].h_in, sp.anchors[5].h_out), (drawn.anchors[1].h_in, drawn.anchors[1].h_out));
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
// A corner stays one.
curvature_extend(&mut sp, false, pt(700.0, 100.0), EndCurve::Corner);
assert!(!sp.anchors[6].has_in() && !sp.anchors[6].has_out());
// From the start: the old segments come one later, and the new end is the first anchor.
let mut sp = before.clone();
curvature_extend(&mut sp, true, pt(-100.0, 50.0), EndCurve::Keep);
assert_eq!(sp.anchors[0], Anchor::corner(pt(-100.0, 50.0)));
assert!((0..4).all(|i| sp.segment(i + 1) == before.segment(i)));
// A corner end without handles goes on along its segment's tangent.
let old = sp.anchors[1];
assert!(old.has_in() && !old.has_out());
assert!(collinear(old.h_in - old.p, old.p - before.anchors[1].h_in) && (old.h_in - old.p).dot(old.p - before.anchors[1].h_in) > 0.0);
}
#[test]
fn closing_keeps_the_old_segments() {
let before = wave();
let mut sp = before.clone();
curvature_close(&mut sp, false, EndCurve::Keep);
assert!(sp.closed && sp.anchors.len() == 5);
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
// The first anchor gains an incoming handle along its tangent: no kink there.
let f = sp.anchors[0];
assert!(f.has_in() && collinear(f.p - f.h_in, before.anchors[1].h_in - f.p));
}
#[test]
fn inserting_a_point_keeps_the_shape() {
let before = wave();
let mut sp = before.clone();
assert_eq!(curvature_insert(&mut sp, 1, 0.3), Some(2));
assert_eq!(sp.anchors.len(), 6);
assert_eq!(sp.anchors[2].kind, AnchorKind::Smooth);
assert!(lies_on(&sp, &before) && lies_on(&before, &sp));
// On a straight side the point is smooth too, its handles along the side.
let (d, id) = doc_with_rect();
let rect = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
let mut sp = rect.clone();
let i = curvature_insert(&mut sp, 0, 0.5).unwrap();
assert_eq!(sp.anchors[i].kind, AnchorKind::Smooth);
assert!(sp.anchors[i].has_in() && sp.anchors[i].has_out());
assert!(lies_on(&sp, &rect) && lies_on(&rect, &sp));
assert_eq!(curvature_insert(&mut sp, 99, 0.5), None);
}
/// A document with one path, `sp`.
fn doc_with(sp: SubPath) -> (Document, NodeId) {
let mut d = Document::new(800.0, 600.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, PathData::single(sp), Appearance::default_art())).unwrap();
(d, id)
}
/// Path `id` selected, anchor `current` of its first subpath direct-selected (or none).
fn selected(id: NodeId, current: Option<usize>) -> Selection {
let mut s = Selection::default();
s.set([id]);
if let Some(ai) = current {
s.anchors.insert(id, [(0, ai)].into());
}
s
}
/// A context without snapping.
fn quiet<'a>(d: &'a Document, s: &'a Selection, p: &'a crate::PaintDefaults) -> ToolContext<'a> {
ToolContext { smart_guides: false, snap_to_point: false, ..cx(d, s, p) }
}
fn press(t: &mut CurvatureTool, cx: &ToolContext, x: f64, y: f64, alt: bool) -> Vec<Action> {
let ev = PointerEvent::new(PointerKind::Down, x, y).with_mods(Mods { alt, ..Mods::default() });
let mut a = t.pointer(cx, &ev);
a.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y)));
a
}
/// The op and params of a `path.curvatureEdit` action.
fn edit_op(a: &Action) -> Option<(&str, &Value)> {
match a {
Action::Exec(c, v) | Action::Preview(c, v) if c == EDIT => Some((v["op"].as_str()?, v)),
_ => None,
}
}
#[test]
fn a_press_on_a_point_selects_it_and_a_drag_moves_it() {
let (d, id) = doc_with(wave());
let (s, p) = (selected(id, None), paint());
let cx = quiet(&d, &s, &p);
let mut t = CurvatureTool::default();
assert_eq!(t.cursor(&cx, pt(250.0, 81.0), Mods::default()), Cursor::Move);
assert_eq!(t.cursor(&cx, pt(450.0, 61.0), Mods::default()), Cursor::PenContinue);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 251.0, 81.0));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"}))]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0));
assert_eq!(a[0], Action::Begin("Curvature".into()));
let moved = edit_op(&a[1]).map(|(op, v)| (op, v["anchor"].clone(), v["x"].clone(), v["y"].clone()));
assert_eq!(moved, Some(("move", json!(2), json!(260.0), json!(140.0))));
assert!(t.busy());
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]);
// A press without a drag only selects.
assert_eq!(press(&mut t, &cx, 100.0, 0.0, false).len(), 1);
}
#[test]
fn alt_click_or_double_click_toggles_and_delete_removes() {
let (d, id) = doc_with(wave());
let p = paint();
let s = selected(id, None);
let cx = quiet(&d, &s, &p);
let mut t = CurvatureTool::default();
let to = |a: &[Action]| a.iter().find(|a| matches!(a, Action::Exec(c, _) if c == "path.convertAnchor")).cloned();
let a = press(&mut t, &cx, 100.0, 0.0, true);
assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 1 && v["to"] == "corner"), "{a:?}");
assert_eq!(t.cursor(&cx, pt(100.0, 0.0), Mods { alt: true, ..Mods::default() }), Cursor::PenConvert);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, 0.0, 100.0));
assert!(matches!(to(&a), Some(Action::Exec(_, v)) if v["anchor"] == 0 && v["to"] == "smooth"), "{a:?}");
// Delete is the tool's while a point is current, and removes it.
assert!(!t.claims_key(&cx, ToolKey::Delete));
let s = selected(id, Some(2));
let cx = quiet(&d, &s, &p);
assert!(t.claims_key(&cx, ToolKey::Backspace));
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![remove_anchor((id, 0, 2))]);
// Esc lets go of the point.
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
}
#[test]
fn a_press_on_a_segment_adds_a_point_and_a_drag_moves_it_in_one_step() {
let (d, id) = doc_with(wave());
let (s, p) = (selected(id, None), paint());
let cx = quiet(&d, &s, &p);
let mut t = CurvatureTool::default();
let on = wave().segment(1).eval(0.5);
assert_eq!(t.cursor(&cx, on, Mods::default()), Cursor::PenAdd);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on.x, on.y));
assert_eq!(a[0], Action::Begin("Curvature".into()));
assert!(
matches!(edit_op(&a[1]), Some(("insert", v)) if v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && v.get("x").is_none())
);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, on.x + 1.0, on.y + 40.0));
assert!(matches!(edit_op(&a[0]), Some(("insert", v)) if v["segment"] == 1 && v["y"] == on.y + 40.0), "{a:?}");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, on.x, on.y + 40.0)), vec![Action::Commit]);
}
#[test]
fn clicks_go_on_from_a_current_end_and_the_other_end_closes() {
let p = paint();
let mut sp = wave();
let mut t = CurvatureTool::default();
let (d, id) = doc_with(sp.clone());
let s = selected(id, Some(4));
let a = press(&mut t, &quiet(&d, &s, &p), 550.0, 120.0, false);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "end" && v["from"] == "keep" && v["x"] == 550.0), "{a:?}");
// As the engine does it: the new end is current, and the next click curves through it.
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep);
let (d, id) = doc_with(sp.clone());
let s = selected(id, Some(5));
let cx = quiet(&d, &s, &p);
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 650.0, 60.0));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the rubber band shows");
let a = press(&mut t, &cx, 650.0, 60.0, true);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["from"] == "smooth"), "{a:?}");
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth);
let (d, id) = doc_with(sp.clone());
let s = selected(id, Some(6));
let cx = quiet(&d, &s, &p);
// The Alt-clicked point stays a corner; a click on the first point closes.
assert_eq!(t.cursor(&cx, pt(0.0, 100.0), Mods::default()), Cursor::PenClose);
let a = press(&mut t, &cx, 0.0, 100.0, false);
assert!(matches!(edit_op(&a[0]), Some(("close", v)) if v["from"] == "corner" && v["end"] == "end"), "{a:?}");
// Any other end keeps its curve; the start goes on from the start.
let s = selected(id, Some(0));
let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false);
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}");
}
}
+61 -8
View File
@@ -5,7 +5,9 @@
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. The Pencil's new path starts and ends on
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
//! between follow the hand. The other tools collect the drag polyline (shown as an overlay) and run
//! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The
//! other tools collect the drag polyline (shown as an overlay) and run
//! one command on release.
use serde_json::{Value, json};
@@ -19,6 +21,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
pub struct GestureTool {
id: &'static str,
points: Vec<Point>,
/// The pen pressure of each of `points` (the Paintbrush only).
pressures: Vec<f32>,
active: bool,
began: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
@@ -53,6 +57,7 @@ impl GestureTool {
Self {
id,
points: vec![],
pressures: vec![],
active: false,
began: false,
extend: None,
@@ -74,6 +79,30 @@ impl GestureTool {
matches!(self.id, "pencil" | "paintbrush")
}
/// Add a stroke sample.
fn push(&mut self, p: Point, pressure: f32) {
self.points.push(p);
if self.id == "paintbrush" {
self.pressures.push(pressure);
}
}
/// Forget the stroke.
fn clear(&mut self) {
self.points.clear();
self.pressures.clear();
}
/// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere
/// along it (a mouse always presses fully).
fn stroke_json(&self) -> Value {
if self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0) {
Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0])).collect())
} else {
points_json(&self.points)
}
}
fn label(&self) -> &'static str {
match self.id {
"paintbrush" => "Paintbrush",
@@ -110,7 +139,7 @@ impl GestureTool {
/// Parameters of `path.freehand` for the current stroke.
pub fn freehand_params(&self, closed: bool) -> Value {
let mut v = json!({
"points": points_json(&self.points),
"points": self.stroke_json(),
"fidelity": self.fidelity,
"closed": closed,
"style": if self.id == "paintbrush" { "brush" } else { "pencil" },
@@ -168,7 +197,8 @@ impl Tool for GestureTool {
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
// A path continued starts at its end; a new one where it snaps.
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
self.points = vec![start];
self.clear();
self.push(start, ev.pressure);
vec![]
}
PointerKind::Drag => {
@@ -182,7 +212,7 @@ impl Tool for GestureTool {
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
self.points.push(p);
self.push(p, ev.pressure);
if !self.draws_path() {
return vec![];
}
@@ -201,7 +231,7 @@ impl Tool for GestureTool {
let end = if self.snaps() { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.points.push(end);
self.push(end, ev.pressure);
} else if self.snaps()
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
@@ -212,7 +242,7 @@ impl Tool for GestureTool {
let out = self.finish(cx, ev.mods);
self.active = false;
self.began = false;
self.points.clear();
self.clear();
self.extend = None;
out
}
@@ -226,7 +256,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
self.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => {
@@ -245,7 +275,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
self.clear();
if began { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
@@ -321,6 +351,29 @@ mod tests {
assert_eq!(v["points"][0], json!([102.0, 98.0]));
}
/// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and
/// the Pencil send plain points.
#[test]
fn paintbrush_sends_pen_pressure() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let stroke = |id: &str, pressures: [f32; 3]| {
let mut t = GestureTool::new(id);
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up];
let mut last = vec![];
for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) {
last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) });
}
let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") };
v["points"].clone()
};
assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]]));
assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
}
#[test]
fn pencil_previews_and_commits() {
let (d, _) = doc_with_rect();
+122 -10
View File
@@ -1,8 +1,9 @@
//! Flare, Reshape, Shaper and Graph tools.
//!
//! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options),
//! then a click sets the end point of the rings. Reshape: drag a point of a selected path; the
//! engine's `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly.
//! then a click sets the end point of the rings; the rest comes from the tool's options (the Flare
//! Tool Options, which last). Reshape: drag a point of a selected path; the engine's
//! `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Vec2};
@@ -15,13 +16,35 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
"flare" => Box::new(FlareTool::default()),
"reshape" => Box::new(ReshapeTool::default()),
"shaper" => Box::new(ShaperTool::default()),
id if vectorcraft_doc::GraphKind::parse(id).is_some() && id.ends_with("Graph") => Box::new(GraphTool::new(id)),
id if is_graph_tool(id) => Box::new(GraphTool::new(id)),
_ => return None,
})
}
/// Whether `id` is one of the graph tools (`columnGraph`, `pieGraph`…).
pub fn is_graph_tool(id: &str) -> bool {
id.ends_with("Graph") && vectorcraft_doc::GraphKind::parse(id).is_some()
}
/// The Flare Tool Options as `shape.flare` takes them: (key, default, least, most). The diameter
/// and the rings' path length are in points, the counts whole, the direction in degrees, the
/// rest percentages.
pub const FLARE_OPTIONS: [(&str, f64, f64, f64); 12] = [
("diameter", 100.0, 0.0, 1000.0),
("opacity", 50.0, 0.0, 100.0),
("brightness", 30.0, 0.0, 100.0),
("growth", 20.0, 0.0, 300.0),
("fuzziness", 50.0, 0.0, 100.0),
("rays", 15.0, 0.0, 50.0),
("longest", 300.0, 0.0, 1000.0),
("rayFuzziness", 100.0, 0.0, 100.0),
("pathLength", 300.0, 0.0, 1000.0),
("rings", 10.0, 0.0, 50.0),
("largest", 50.0, 0.0, 250.0),
("direction", 45.0, 0.0, 360.0),
];
/// Flare Tool: centre drag, then a click for the rings' end point.
#[derive(Default)]
pub struct FlareTool {
/// Pressed at (centre) and the current drag point.
drag: Option<(Point, Point)>,
@@ -29,14 +52,62 @@ pub struct FlareTool {
placed: Option<(Point, f64)>,
hover: Point,
began: bool,
/// Ray count changed with ↑/↓ during the gesture (None = the command's default, 15).
/// Ray count changed with ↑/↓ during the gesture (None = the options' count).
rays: Option<u64>,
/// The options' values, in [`FLARE_OPTIONS`] order.
values: [f64; 12],
/// Whether the flare has rays and rings (the options' Rays and Rings checkboxes).
rays_on: bool,
rings_on: bool,
}
impl Default for FlareTool {
fn default() -> Self {
Self {
drag: None,
placed: None,
hover: Point::ZERO,
began: false,
rays: None,
values: FLARE_OPTIONS.map(|(_, default, _, _)| default),
rays_on: true,
rings_on: true,
}
}
}
/// Whether Flare Tool Options value `key` is a count (whole).
fn is_count(key: &str) -> bool {
matches!(key, "rays" | "rings")
}
/// Flare Tool Options value `x` of `key` as JSON: a count as a whole number.
fn option_json(key: &str, x: f64) -> Value {
if is_count(key) { json!(x as u64) } else { json!(x) }
}
/// `shape.flare`'s params for a flare at `c` drawn with the Flare Tool Options `o` (as the tool
/// reports them, [`Tool::options`]): with Rays or Rings off it has none.
pub fn flare_params(o: &Value, c: Point) -> Value {
let mut v = json!({ "cx": c.x, "cy": c.y });
for (key, default, ..) in FLARE_OPTIONS {
v[key] = o.get(key).cloned().unwrap_or_else(|| option_json(key, default));
}
for (on, key) in [("raysOn", "rays"), ("ringsOn", "rings")] {
if o.get(on).and_then(Value::as_bool) == Some(false) {
v[key] = json!(0);
}
}
v
}
impl FlareTool {
/// `shape.flare`'s params: the options, with the dragged centre and diameter, the ray count
/// ↑/↓ chose and the rings' end point once clicked.
fn params(&self, c: Point, diameter: f64, end: Option<Point>) -> Value {
let mut v = json!({ "cx": c.x, "cy": c.y, "diameter": diameter.max(1.0) });
if let Some(r) = self.rays {
let mut v = flare_params(&self.options(), c);
v["diameter"] = json!(diameter.max(1.0));
if let Some(r) = self.rays.filter(|_| self.rays_on) {
v["rays"] = json!(r);
}
if let Some(e) = end {
@@ -60,6 +131,7 @@ impl Tool for FlareTool {
PointerKind::Down => {
if self.placed.is_none() {
self.drag = Some((ev.pos, ev.pos));
self.rays = None;
}
vec![]
}
@@ -106,8 +178,8 @@ impl Tool for FlareTool {
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
// ↑/↓ while drawing add/remove rays.
if matches!(key, ToolKey::Up | ToolKey::Down) && self.began {
let r = self.rays.unwrap_or(15);
if matches!(key, ToolKey::Up | ToolKey::Down) && self.began && self.rays_on {
let r = self.rays.unwrap_or_else(|| self.options()["rays"].as_u64().unwrap_or(0));
self.rays = Some(if key == ToolKey::Up { (r + 1).min(50) } else { r.saturating_sub(1) });
let preview = match (self.drag, self.placed) {
(Some((c, p)), _) => self.params(c, 2.0 * p.distance(c), None),
@@ -126,7 +198,7 @@ impl Tool for FlareTool {
vec![]
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
// Switching away after the centre drag keeps the flare with its default rings.
// Switching away after the centre drag keeps the flare with the rings its options give.
self.drag = None;
if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] }
}
@@ -142,6 +214,24 @@ impl Tool for FlareTool {
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
let mut v = json!({ "raysOn": self.rays_on, "ringsOn": self.rings_on });
for ((key, ..), x) in FLARE_OPTIONS.iter().zip(self.values) {
v[*key] = option_json(key, x);
}
v
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"raysOn" => self.rays_on = v.as_bool().unwrap_or(true),
"ringsOn" => self.rings_on = v.as_bool().unwrap_or(true),
_ => {
let Some((slot, (_, default, lo, hi))) = self.values.iter_mut().zip(FLARE_OPTIONS).find(|(_, (k, ..))| *k == key) else { return };
let x = v.as_f64().filter(|x| x.is_finite()).unwrap_or(default).clamp(lo, hi);
*slot = if is_count(key) { x.round() } else { x };
}
}
}
}
/// Reshape Tool: drag a point on a selected path.
@@ -378,6 +468,28 @@ impl Tool for GraphTool {
mod tests {
use super::*;
/// The Flare Tool Options: kept within their ranges, and what the next drag draws with.
#[test]
fn flare_options_last_and_draw_the_next_flare() {
let mut t = FlareTool::default();
assert_eq!(t.options()["rays"], json!(15));
for (k, v) in [("diameter", json!(5000)), ("rays", json!(7.6)), ("opacity", json!("x")), ("ringsOn", json!(false)), ("nope", json!(1))] {
t.set_option(k, &v);
}
let o = t.options();
assert_eq!(
(o["diameter"].as_f64(), o["rays"].as_u64(), o["opacity"].as_f64(), o["ringsOn"].as_bool()),
(Some(1000.0), Some(8), Some(50.0), Some(false))
);
assert!(o.get("nope").is_none());
// A drag sets the centre and diameter; the options give the rest, rings off.
let p = t.params(Point::new(10.0, 20.0), 40.0, None);
assert_eq!((p["cx"].as_f64(), p["diameter"].as_f64(), p["rays"].as_u64(), p["rings"].as_u64()), (Some(10.0), Some(40.0), Some(8), Some(0)));
assert_eq!(p["longest"].as_f64(), Some(300.0));
t.set_option("raysOn", &json!(false));
assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0));
}
#[test]
fn create_covers_both() {
assert_eq!(create("flare").unwrap().id(), "flare");
+1 -10
View File
@@ -27,7 +27,7 @@ use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use crate::direct::hit_handle;
use crate::draw2::AnchorTool;
use crate::draw2::{AnchorTool, editable_paths};
use crate::guides::{DrawSnap, Leave};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -122,15 +122,6 @@ fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
|| crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some())
}
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
}
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
Action::Preview(
+23 -1
View File
@@ -14,7 +14,8 @@ type Row = (&'static [&'static str], Option<&'static str>, &'static [&'static st
/// The Liquify tools.
pub const LIQUIFY: &[&str] = &["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"];
const SYMBOLISM: &[&str] =
/// The Symbolism tools.
pub const SYMBOLISM: &[&str] =
&["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"];
const ROWS: &[Row] = &[
@@ -35,6 +36,27 @@ const ROWS: &[Row] = &[
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
(&["artboard"], None, &["moveArt", "scaleArt"]),
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
(
&["flare"],
None,
&[
"diameter",
"opacity",
"brightness",
"growth",
"fuzziness",
"raysOn",
"rays",
"longest",
"rayFuzziness",
"ringsOn",
"pathLength",
"rings",
"largest",
"direction",
],
),
];
/// The stores `tool`'s persistent options live in, each with the option keys it holds.
+1
View File
@@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-render = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-tools = { workspace = true }
+72 -13
View File
@@ -512,13 +512,16 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
let alt_id = egui::Id::new("canvas-alt-wheel");
let alt_before = ui.data(|d| d.get_temp(alt_id)).unwrap_or(false);
let mut alt_turn = alt_before;
let turn_id = egui::Id::new("canvas-wheel-turn");
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
let mut turn = turn_before;
let (pointer, m, space, (factor, scroll)) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut alt_turn)));
if alt_turn != alt_before {
ui.data_mut(|d| d.insert_temp(alt_id, alt_turn));
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn)));
if turn != turn_before {
ui.data_mut(|d| d.insert_temp(turn_id, turn));
}
if turn.glide != 0.0 {
ui.ctx().request_repaint();
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
@@ -717,6 +720,18 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
const WHEEL_ZOOM: f64 = 0.01;
/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of
/// the part still to come.
const WHEEL_GLIDE: f64 = 0.05;
/// What the wheel did across frames ([`wheel`]).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct WheelTurn {
/// The last wheel turn was an Alt-wheel one.
alt: bool,
/// The zoom (natural log) wheel notches still have to glide in.
glide: f64,
}
/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it).
const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0;
/// Animated Zoom: how far the pointer moves before a press is a drag, in points.
@@ -754,19 +769,19 @@ fn marquee(p: &egui::Painter, r: egui::Rect) {
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
/// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the
/// caller across frames).
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) {
/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom
/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once.
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) {
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
if !wheel_zooms {
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
*alt_turn = m.alt && !m.command;
turn.alt = m.alt && !m.command;
}
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
return if *alt_turn && scroll != egui::Vec2::ZERO {
return if turn.alt && scroll != egui::Vec2::ZERO {
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan)
} else {
(zoom, scroll + pan)
@@ -787,16 +802,23 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
scroll.x += d.x + d.y;
} else if modifiers.shift {
scroll.y += d.x + d.y;
} else {
} else if *unit == egui::MouseWheelUnit::Point {
zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp();
scroll.x += d.x;
} else {
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
scroll.x += d.x;
}
}
egui::Event::Zoom(f) => zoom *= f64::from(*f),
_ => {}
}
}
(zoom, scroll)
// This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds.
let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() };
let step = turn.glide * part;
turn.glide -= step;
(zoom * step.exp(), scroll)
}
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
@@ -2868,6 +2890,43 @@ mod tests {
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
}
/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a
/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom.
#[test]
fn wheel_notches_zoom_in_a_glide() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
let ctx = egui::Context::default();
let run = |app: &mut VectorcraftApp| {
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
app.raw_input_hook(&mut raw);
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
run(&mut app);
let at = app.canvas_rect.unwrap().center();
let before = app.view().unwrap().zoom;
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1}));
// The first frame the zoom moves in (the event may land a frame later).
let mut first = 1.0;
for _ in 0..3 {
run(&mut app);
first = app.view().unwrap().zoom / before;
if first != 1.0 {
break;
}
}
for _ in 0..60 {
run(&mut app);
}
let done = app.view().unwrap().zoom / before;
assert!(done > 1.01, "the notch zooms in ({done})");
assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})");
run(&mut app);
assert_eq!(app.view().unwrap().zoom / before, done, "and it stops");
}
/// Two fingers dragged together on a touch screen pan the canvas with them, under either
/// Zoom with Mouse Wheel setting, and draw nothing (#449).
#[test]
+21 -2
View File
@@ -1,5 +1,5 @@
//! Artboard Options (double-click with the Artboard tool): the artboard's fields, sizes parsed
//! with units.
//! Artboard Options (double-click with the Artboard tool, or double-click the tool): the
//! artboard's fields, sizes parsed with units.
use serde_json::{Value, json};
@@ -9,6 +9,25 @@ use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
/// The dialog kind of Artboard Options.
pub const KIND: &str = "artboardOptions";
/// Open Artboard Options for the active artboard: the Artboard tool's while it is in use, else the
/// window's.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let index = if app.session.tool_id() == "artboard" {
app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0)
} else {
app.view().map_or(0, |v| v.artboard)
};
let st = app.session.active().ok_or("no document")?;
let a = st.doc.artboards.get(index).ok_or("no artboard")?;
let r = a.rect;
let fields = json!({"index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(json!({ "dialog": KIND }))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
+344
View File
@@ -0,0 +1,344 @@
//! Brush Options (double-click a brush in the Brushes panel, or the panel menu's Brush Options…).
//! Calligraphic Brush Options: Name, then Angle, Roundness and Size, each Fixed, Random or
//! Pressure with its Variation, over live previews of the nib and of a stroke (light, heavy, light
//! pressure). Bristle Brush Options: Name, Shape and the bristle sliders. OK runs `brush.options`
//! (strokes painted with the brush update); a name another brush has keeps the dialog open.
//!
//! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle`), `name`.
//! Calligraphic: `angle` (−180..180°), `roundness` (0..100 %), `size` (0..1296 pt), and per value
//! `angleMode`, `roundnessMode`, `sizeMode` (`fixed` | `random` | `pressure`) and
//! `angleVariation`, `roundnessVariation`, `sizeVariation`. Bristle: `shape` (`roundPoint`…),
//! `size` (pt), `length` (25..300 %), `density`, `thickness`, `opacity`, `stiffness` (1..100 %).
use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Brush Options.
pub const KIND: &str = "brushOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 440.0, ..DialogSpec::FORM };
/// The brush types that have an options dialog.
pub const TYPES: [&str; 2] = ["calligraphic", "bristle"];
/// Calligraphic values: (field, label, suffix, decimals), in [`Calligraphic::RANGES`] order.
const CALLI: [(&str, &str, &str, usize); 3] = [("angle", "Angle:", "°", 0), ("roundness", "Roundness:", "%", 0), ("size", "Size:", " pt", 1)];
/// Bristle sliders: (field, label, min, max), in percent.
const BRISTLE: [(&str, &str, f64, f64); 5] = [
("length", "Bristle Length:", 25.0, 300.0),
("density", "Bristle Density:", 1.0, 100.0),
("thickness", "Bristle Thickness:", 1.0, 100.0),
("opacity", "Paint Opacity:", 1.0, 100.0),
("stiffness", "Stiffness:", 1.0, 100.0),
];
/// Bristle tip shapes: (value, label).
const SHAPES: [(&str, &str); 10] = [
("roundPoint", "Round Point"),
("flatPoint", "Flat Point"),
("roundBlunt", "Round Blunt"),
("flatBlunt", "Flat Blunt"),
("roundCurve", "Round Curve"),
("flatCurve", "Flat Curve"),
("roundAngle", "Round Angle"),
("flatAngle", "Flat Angle"),
("roundFan", "Round Fan"),
("flatFan", "Flat Fan"),
];
/// Width of the bristle dialog's label column.
const LABEL_W: f32 = 112.0;
/// Widths of the nib preview and of the whole preview row.
const NIB_W: f32 = 120.0;
const STROKE_W: f32 = 400.0;
fn heading(d: &Dialog) -> String {
match d.str("__type").as_str() {
"bristle" => tl!("Bristle Brush Options").into(),
_ => tl!("Calligraphic Brush Options").into(),
}
}
fn mode_key(key: &str) -> String {
format!("{key}Mode")
}
fn variation_key(key: &str) -> String {
format!("{key}Variation")
}
/// Open the options of brush `name` (default: the selected path's brush, else the current one).
pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, String> {
let name = match name {
Some(n) => n.to_string(),
None => target(app).ok_or("no brush selected")?,
};
let def = app.run("brush.get", json!({ "name": name }))?;
let b: Brush = serde_json::from_value(def).map_err(|e| e.to_string())?;
let mut f = Map::new();
f.insert("__brush".into(), json!(b.name));
f.insert("name".into(), json!(b.name));
f.insert("__type".into(), json!(b.kind.type_id()));
match &b.kind {
BrushKind::Calligraphic(c) => {
let values = [c.angle, c.roundness, c.size];
for (i, (key, ..)) in CALLI.iter().enumerate() {
let (Some(v), Some(var), Some(mode)) = (values.get(i), c.variation.get(i), c.modes().get(i).copied()) else { continue };
f.insert((*key).into(), json!(v));
f.insert(variation_key(key), json!(var));
f.insert(mode_key(key), json!(mode.id()));
}
}
BrushKind::Bristle(b) => {
let v = serde_json::to_value(b).map_err(|e| e.to_string())?;
for key in ["shape", "size"].into_iter().chain(BRISTLE.map(|r| r.0)) {
f.insert(key.into(), v.get(key).cloned().unwrap_or(Value::Null));
}
}
_ => return Err("this kind of brush has no options dialog yet".into()),
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
Ok(Value::Null)
}
/// Can Brush Options open without a name (its brush has a type with a dialog)?
pub fn available(app: &VectorcraftApp) -> bool {
let Some(st) = app.session.active() else { return false };
target(app).and_then(|n| vectorcraft_brush::find(&st.doc, &n)).is_some_and(|b| TYPES.contains(&b.kind.type_id()))
}
/// The brush Brush Options opens on without a name: the selected path's brush, else the current one.
fn target(app: &VectorcraftApp) -> Option<String> {
let st = app.session.active()?;
let selected = st.selection.subjects().first().and_then(|id| st.doc.node(*id)).and_then(|n| n.appearance.stroke()?.brush.clone());
selected.or_else(|| vectorcraft_brush::current(&st.doc))
}
/// The value of field `key` within `range`.
fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
d.f64(key, lo).clamp(lo, hi)
}
/// The Calligraphic brush the fields describe.
fn calligraphic(d: &Dialog) -> Calligraphic {
let value = |i: usize| CALLI.get(i).zip(Calligraphic::RANGES.get(i)).map_or(0.0, |((key, ..), r)| clamped(d, key, *r));
let variation =
|i: usize| CALLI.get(i).zip(Calligraphic::MAX_VARIATION.get(i)).map_or(0.0, |((key, ..), max)| clamped(d, &variation_key(key), (0.0, *max)));
let mode = |i: usize| CALLI.get(i).and_then(|(key, ..)| Variation::parse(&d.str(&mode_key(key)))).unwrap_or_default();
Calligraphic { angle: value(0), roundness: value(1), size: value(2), variation: [0, 1, 2].map(variation), modes: Some([0, 1, 2].map(mode)) }
}
/// `brush.options` params from the fields.
fn params(d: &Dialog) -> Value {
match d.str("__type").as_str() {
"bristle" => {
let mut p = json!({ "shape": d.str("shape"), "size": clamped(d, "size", (0.1, 1296.0)) });
for (key, _, lo, hi) in BRISTLE {
p[key] = json!(clamped(d, key, (lo, hi)));
}
p
}
_ => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
grid(ui, |ui| {
label(ui, tl!("Name:"));
form::text(ui, d, "name", 240.0);
ui.end_row();
});
ui.add_space(10.0);
match d.str("__type").as_str() {
"bristle" => bristle_body(app, ui, d),
_ => calligraphic_body(ui, d),
}
false
}
fn calligraphic_body(ui: &mut egui::Ui, d: &mut Dialog) {
let c = calligraphic(d);
ui.horizontal(|ui| {
nib_preview(ui, &c, vec2(NIB_W, 84.0));
ui.add_space(8.0);
stroke_preview(ui, "calligraphic", serde_json::to_value(&c).unwrap_or_default(), vec2(STROKE_W - NIB_W - 8.0, 84.0));
});
ui.add_space(12.0);
egui::Grid::new("brush-options-calli").num_columns(5).spacing([8.0, 8.0]).show(ui, |ui| {
for (i, (key, text, suffix, decimals)) in CALLI.into_iter().enumerate() {
let (Some(&range), Some(&max)) = (Calligraphic::RANGES.get(i), Calligraphic::MAX_VARIATION.get(i)) else { continue };
label(ui, tl!(text));
if let Some(v) = widgets::range_field(ui, ("brush-value", key), clamped(d, key, range), range.0..=range.1, suffix, decimals, 64.0) {
d.fields.insert(key.into(), json!(v));
}
let mode = Variation::parse(&d.str(&mode_key(key))).unwrap_or_default();
let labels = Variation::ALL.map(Variation::label);
if let Some(m) = widgets::dropdown(ui, ("brush-mode", key), mode.label(), &labels, 104.0).and_then(|i| Variation::ALL.get(i)) {
d.fields.insert(mode_key(key), json!(m.id()));
}
label(ui, tl!("Variation:"));
let vkey = variation_key(key);
ui.add_enabled_ui(mode != Variation::Fixed, |ui| {
if let Some(v) = widgets::range_field(ui, ("brush-var", key), clamped(d, &vkey, (0.0, max)), 0.0..=max, suffix, decimals, 64.0) {
d.fields.insert(vkey.clone(), json!(v));
}
});
ui.end_row();
}
});
}
fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
form::choice(ui, d, "shape", tl!("Shape:"), (LABEL_W, 150.0), &SHAPES);
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
form::length(ui, d, "size", app.session.general_unit(), 80.0);
});
let rail = Tokens::get(ui.ctx()).input_border;
for (key, text, lo, hi) in BRISTLE {
widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field(ui, d, key, lo..=hi, "%", &|_| rail));
}
ui.add_space(8.0);
let p = params(d);
stroke_preview(ui, "bristle", p, vec2(STROKE_W, 64.0));
}
/// A stroke painted with the brush the fields describe (type `ty`, definition fields `def`).
fn stroke_preview(ui: &mut egui::Ui, ty: &str, mut def: Value, size: egui::Vec2) {
def["name"] = json!("preview");
def["type"] = json!(ty);
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
crate::panels::brushes::chip(ui, r, &def);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Tokens::get(ui.ctx()).input_border), egui::StrokeKind::Inside);
}
/// The nib of `c` at its values (black), and — for values that vary — at their lightest and
/// heaviest (grey), on white like the brush previews, its largest nib filling the box.
fn nib_preview(ui: &mut egui::Ui, c: &Calligraphic, size: egui::Vec2) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
let p = ui.painter_at(r);
p.rect_filled(r, 0.0, Color32::WHITE);
let guide = Stroke::new(1.0, Color32::from_gray(220));
p.line_segment([pos2(r.left(), r.center().y), pos2(r.right(), r.center().y)], guide);
p.line_segment([pos2(r.center().x, r.top()), pos2(r.center().x, r.bottom())], guide);
let values = [c.angle, c.roundness, c.size];
let at = |sign: f64| -> [f64; 3] {
let mut v = values;
for (i, (x, mode)) in v.iter_mut().zip(c.modes()).enumerate() {
let (Some(&var), Some(&(lo, hi))) = (c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode != Variation::Fixed {
*x = (*x + sign * var).clamp(lo, hi);
}
}
v
};
let nibs = [at(-1.0), at(1.0), values];
let largest = nibs.iter().map(|n| n[2]).fold(0.0, f64::max).max(1e-6);
let scale = (r.height().min(r.width()) - 12.0) / largest as f32;
for (k, [angle, round, size]) in nibs.into_iter().enumerate() {
let fill = if k < 2 { Color32::from_gray(200) } else { Color32::BLACK };
let (a, b) = ((size as f32 * scale / 2.0).max(0.5), (size * round.clamp(1.0, 100.0) / 100.0) as f32 * scale / 2.0);
let th = (angle as f32).to_radians();
let (u, v) = (vec2(th.cos(), -th.sin()), vec2(th.sin(), th.cos()));
let pts: Vec<egui::Pos2> = (0..48)
.map(|i| {
let s = i as f32 / 48.0 * std::f32::consts::TAU;
r.center() + u * (a * s.cos()) + v * (b.max(0.5) * s.sin())
})
.collect();
p.add(egui::Shape::convex_polygon(pts, fill, Stroke::NONE));
}
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
}
/// OK: edit the brush (and rename it). A name another brush has keeps the dialog open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let name = d.str("name").trim().to_string();
let r = app.run("brush.options", json!({ "name": d.str("__brush"), "newName": name, "params": params(d) }));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app
}
fn set(app: &mut VectorcraftApp, fields: Value) {
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in fields.as_object().unwrap() {
d.fields.insert(k.clone(), v.clone());
}
}
fn shown(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let text = shown(&mut app);
for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0));
// Out-of-range values are kept within their ranges.
set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4}));
assert!(shown(&mut app).contains("Pressure"));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap();
assert_eq!(def["modes"], json!(["random", "fixed", "pressure"]));
assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0)));
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0));
}
#[test]
fn a_taken_name_keeps_the_dialog_open() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
set(&mut app, json!({"name": "3 pt. Round"}));
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
#[test]
fn bristle_options_and_brushes_without_a_dialog() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
let text = shown(&mut app);
for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] {
assert!(text.contains(label), "{label} in {text}");
}
set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap();
assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0)));
// Art, scatter and pattern brushes have no dialog yet: an error, nothing opens.
assert!(app.run("ui.brushOptions", json!({"name": "Arrow"})).is_err());
assert!(app.ui.dialog.is_none());
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
}
}
+10 -6
View File
@@ -63,12 +63,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, tl!("Corner:"));
ui.vertical(|ui| {
for k in CornerKind::ALL {
let name = match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
};
if widgets::radio(ui, name, Some(k) == current, true) {
if widgets::radio(ui, kind_label(k), Some(k) == current, true) {
d.fields.insert("kind".into(), json!(k));
}
}
@@ -81,6 +76,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// A corner kind's name, in the UI language.
pub(crate) fn kind_label(k: CornerKind) -> &'static str {
match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = json!({"id": d.fields.get("id"), "corners": d.fields.get("corners")});
if let Some(k) = d.fields.get("kind") {
+203
View File
@@ -0,0 +1,203 @@
//! Flare Tool Options (double-click the Flare tool, or click with it): the options the Flare tool
//! keeps, which draw the next flare dragged out, last across tool switches and are saved with the
//! preferences. OK sets them with `tool.setOption {tool: "flare", values}`; opened by a click, it
//! then draws a flare there with them (`shape.flare`, on the active perspective plane while the
//! grid shows).
//!
//! Fields: the options (`vectorcraft_tools::extra::FLARE_OPTIONS`): `diameter` and `pathLength`
//! (points), `opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and
//! `largest` (%), `rays` and `rings` (counts), `direction` (degrees); `raysOn` and `ringsOn`; and
//! `x`, `y` where a click opened it.
use serde_json::{Map, Value, json};
use vectorcraft_geom::Point;
use vectorcraft_tools::extra::{FLARE_OPTIONS, flare_params};
use super::document_setup::check;
use super::{DialogResult, DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Flare Tool Options.
pub const KIND: &str = "flareOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Flare Tool Options").into(), body, confirm, min_width: 440.0, ..DialogSpec::FORM };
/// Width of the label column and of the fields.
const LABEL_W: f32 = 78.0;
const FIELD_W: f32 = 90.0;
/// Open the Flare Tool Options with the tool's values; `at`: where a click opened them, to draw a
/// flare there on OK.
pub fn open(app: &mut VectorcraftApp, at: Option<Point>) -> Value {
let mut fields = match app.session.tool_options_of("flare") {
Value::Object(o) => o,
_ => Map::new(),
};
if let Some(p) = at {
fields.insert("x".into(), json!(p.x));
fields.insert("y".into(), json!(p.y));
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
json!({ "dialog": KIND })
}
/// A row for option `key`, kept within its range: a distance in `unit`, else a number.
fn field(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit, (key, label): (&str, &str)) {
let Some(&(_, default, lo, hi)) = FLARE_OPTIONS.iter().find(|(k, ..)| *k == key) else { return };
widgets::label_row(ui, label, LABEL_W, |ui| match key {
"diameter" | "pathLength" => {
if form::length(ui, d, key, unit, FIELD_W) {
let v = d.f64(key, default).clamp(lo, hi);
d.fields.insert(key.into(), json!(v));
}
}
_ => {
let suffix = match key {
"rays" | "rings" => "",
"direction" => "°",
_ => "%",
};
if let Some(v) = widgets::range_field(ui, ("flare", key), d.f64(key, default), lo..=hi, suffix, 0, FIELD_W) {
d.fields.insert(key.into(), json!(v));
}
}
});
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
// Center and Halo, then Rays and Rings, side by side as two rows of groups.
egui::Grid::new("flare-groups").num_columns(2).spacing([28.0, 14.0]).show(ui, |ui| {
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Center"));
for f in [("diameter", tl!("Diameter:")), ("opacity", tl!("Opacity:")), ("brightness", tl!("Brightness:"))] {
field(ui, d, unit, f);
}
});
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Halo"));
for f in [("growth", tl!("Growth:")), ("fuzziness", tl!("Fuzziness:"))] {
field(ui, d, unit, f);
}
});
ui.end_row();
ui.vertical(|ui| {
check(ui, d, "raysOn", tl!("Rays"));
let on = d.bool("raysOn");
ui.add_enabled_ui(on, |ui| {
for f in [("rays", tl!("Number:")), ("longest", tl!("Longest:")), ("rayFuzziness", tl!("Fuzziness:"))] {
field(ui, d, unit, f);
}
});
});
ui.vertical(|ui| {
check(ui, d, "ringsOn", tl!("Rings"));
let on = d.bool("ringsOn");
ui.add_enabled_ui(on, |ui| {
for f in [("pathLength", tl!("Path:")), ("rings", tl!("Number:")), ("largest", tl!("Largest:")), ("direction", tl!("Direction:"))] {
field(ui, d, unit, f);
}
});
});
ui.end_row();
});
false
}
/// OK: set the tool's options; opened by a click, draw a flare there with them.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let mut values: Map<String, Value> =
FLARE_OPTIONS.iter().filter(|(k, ..)| d.fields.contains_key(*k)).map(|(k, default, ..)| (k.to_string(), json!(d.f64(k, *default)))).collect();
for key in ["raysOn", "ringsOn"] {
values.insert(key.into(), json!(d.bool(key)));
}
let at = d.fields.get("x").and_then(Value::as_f64).zip(d.fields.get("y").and_then(Value::as_f64)).map(|(x, y)| Point::new(x, y));
let options = run_and_close(app, "tool.setOption", json!({ "tool": "flare", "values": values }))?;
let Some(at) = at else { return Ok(options) };
let params = flare_params(&options, at);
match vectorcraft_engine::perspective_click(&app.session, "shape.flare", &params, at) {
Some((id, p)) => app.run(&id, p),
None => app.run("shape.flare", params),
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_engine::doc::NodeKind;
use vectorcraft_tools::{PointerEvent, PointerKind};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 600})).unwrap();
app
}
/// The labels the open dialog paints.
fn painted(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
/// The children of the newest flare (a group named Flare), by name.
fn flare_parts(app: &VectorcraftApp) -> Vec<String> {
let doc = &app.session.doc().unwrap().doc;
let flare = doc.layers[0].children().unwrap().iter().rev().find(|n| n.name.as_deref() == Some("Flare")).expect("a flare");
let NodeKind::Group { children, .. } = &flare.kind else { panic!("a flare is a group") };
children.iter().filter_map(|c| c.name.clone()).collect()
}
#[test]
fn the_toolbar_opens_the_options_and_ok_keeps_them_for_the_next_drag() {
let mut app = app();
app.run("tool.options", json!({"tool": "flare"})).unwrap();
let text = painted(&mut app);
for label in ["Flare Tool Options", "Center", "Diameter:", "Halo", "Growth:", "Rays", "Longest:", "Rings", "Path:", "Largest:", "Direction:"]
{
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.f64("diameter", 0.0), d.f64("rays", 0.0), d.bool("raysOn"), d.f64("direction", 0.0)), (100.0, 15.0, true, 45.0));
for (k, v) in [("rays", json!(4)), ("ringsOn", json!(false)), ("growth", json!(80))] {
d.fields.insert(k.into(), v);
}
let objects = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(objects(&app), 0, "OK from the toolbar draws nothing");
let o = app.session.tool_options_of("flare");
assert_eq!((o["rays"].as_u64(), o["ringsOn"].as_bool(), o["growth"].as_f64()), (Some(4), Some(false), Some(80.0)));
// A drag draws with them: four rays, no rings.
app.select_tool("flare");
let view = app.view_info();
for (kind, x, y) in [(PointerKind::Down, 200.0, 200.0), (PointerKind::Drag, 240.0, 200.0), (PointerKind::Up, 240.0, 200.0)] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, y), view);
}
for kind in [PointerKind::Down, PointerKind::Up] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 400.0, 400.0), view);
}
let parts = flare_parts(&app);
assert!(!parts.iter().any(|p| p == "Ring"), "{parts:?}");
let doc = &app.session.doc().unwrap().doc;
let flare = doc.layers[0].children().unwrap().last().unwrap();
let rays = flare.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Rays")).unwrap();
assert_eq!(rays.path_data().unwrap().subpaths.len(), 4);
}
#[test]
fn a_click_fills_in_the_options_and_draws_a_flare_there() {
let mut app = app();
app.run("tool.setOption", json!({"tool": "flare", "values": {"rings": 3}})).unwrap();
crate::dialogs::open_tool_dialog(&mut app, "flare", json!({"x": 300, "y": 300}));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.f64("rings", 0.0), d.f64("x", 0.0)), (KIND, 3.0, 300.0));
super::super::confirm(&mut app).unwrap();
let parts = flare_parts(&app);
assert_eq!(parts.iter().filter(|p| *p == "Ring").count(), 3, "{parts:?}");
// Out-of-range values are kept within the option's range.
app.run("tool.setOption", json!({"tool": "flare", "values": {"opacity": 900, "diameter": -4}})).unwrap();
let o = app.session.tool_options_of("flare");
assert_eq!((o["opacity"].as_f64(), o["diameter"].as_f64()), (Some(100.0), Some(0.0)));
}
}
+21 -9
View File
@@ -393,18 +393,30 @@ pub(super) fn slider_w(
track: &dyn Fn(f32) -> egui::Color32,
) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
slider_field(ui, d, key, range, suffix, track);
});
}
/// The rail and value field of [`slider`] without its label (for a row that draws its own).
pub(super) fn slider_field(
ui: &mut egui::Ui,
d: &mut Dialog,
key: &str,
range: std::ops::RangeInclusive<f64>,
suffix: &str,
track: &dyn Fn(f32) -> egui::Color32,
) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
let mut new = None;
ui.horizontal(|ui| {
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
new = Some((min + x as f64 * (max - min)).round());
}
if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) {
new = Some(x.round().clamp(min, max));
}
});
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
new = Some((min + x as f64 * (max - min)).round());
}
if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) {
new = Some(x.round().clamp(min, max));
}
if let Some(n) = new.filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
+8 -2
View File
@@ -8,8 +8,9 @@
mod about;
mod all_tools;
mod artboard_options;
pub mod artboard_options;
pub mod blend_options;
pub mod brush_options;
pub mod color_balance;
pub mod color_guide_options;
mod color_picker;
@@ -28,6 +29,7 @@ mod export_as;
mod export_for_screens;
pub mod eyedropper;
pub mod file_info;
pub mod flare_options;
pub mod flatten;
pub mod flattener_presets;
mod form;
@@ -77,6 +79,7 @@ pub mod spot_colors;
pub(crate) mod svg_options;
pub mod swatch_conflict;
pub mod swatch_options;
pub mod symbolism_options;
mod text_export;
pub mod text_import;
mod tiff_bmp_tga;
@@ -203,7 +206,7 @@ registry! {
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC,
ArtboardOptions: [artboard_options::KIND] => artboard_options::SPEC,
AllTools: ["allTools"] => all_tools::SPEC,
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: [recolor::KIND] => recolor::SPEC,
@@ -275,9 +278,12 @@ registry! {
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
FreehandOptions: [freehand::KIND] => freehand::SPEC,
FlareOptions: [flare_options::KIND] => flare_options::SPEC,
SymbolismOptions: [symbolism_options::KIND] => symbolism_options::SPEC,
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
BrushOptions: [brush_options::KIND] => brush_options::SPEC,
EditSelection: [edit_selection::KIND] => edit_selection::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
@@ -0,0 +1,81 @@
//! Symbolism Tools Options (double-click a Symbolism tool): the brush the eight tools share, which
//! lasts across tool switches and is saved with the preferences. OK sets it with
//! `tool.setOption {tool, values}`.
//!
//! Fields: `tool`; `diameter` (points), `intensity` and `density` (Symbol Set Density), 1–10.
use serde_json::{Map, Value, json};
use super::{DialogResult, DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Symbolism Tools Options.
pub const KIND: &str = "symbolismOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Symbolism Tools Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// The options the dialog edits.
const KEYS: [&str; 3] = ["diameter", "intensity", "density"];
/// Width of the label column.
const LABEL_W: f32 = 130.0;
/// Open the options of Symbolism tool `tool` (one of `vectorcraft_tools::settings::SYMBOLISM`)
/// with their current values.
pub fn open(app: &mut VectorcraftApp, tool: &str) -> Value {
let o = app.session.tool_options_of(tool);
let mut fields: Map<String, Value> = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect();
fields.insert("tool".into(), json!(tool));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
json!({ "dialog": KIND })
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
widgets::label_row(ui, tl!("Diameter:"), LABEL_W, |ui| {
form::length(ui, d, "diameter", unit, 100.0);
});
for (key, label) in [("intensity", tl!("Intensity:")), ("density", tl!("Symbol Set Density:"))] {
widgets::label_row(ui, label, LABEL_W, |ui| {
if let Some(v) = widgets::range_field(ui, ("symbolism", key), d.f64(key, 5.0), 1.0..=10.0, "", 0, 100.0) {
d.fields.insert(key.into(), json!(v));
}
});
}
false
}
/// OK: set the brush the Symbolism tools share (the tool keeps it within its ranges).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let values: Map<String, Value> = KEYS.iter().filter(|k| d.fields.contains_key(**k)).map(|k| (k.to_string(), json!(d.f64(k, 0.0)))).collect();
run_and_close(app, "tool.setOption", json!({ "tool": d.str("tool"), "values": values }))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn the_symbolism_tools_share_the_options_ok_sets() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("tool.options", json!({"tool": "symbolSizer"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
for label in ["Symbolism Tools Options", "Diameter:", "Intensity:", "Symbol Set Density:"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.str("tool"), d.f64("diameter", 0.0), d.f64("intensity", 0.0)), ("symbolSizer".to_string(), 80.0, 5.0));
for (k, v) in [("diameter", json!(120)), ("intensity", json!(9)), ("density", json!(2))] {
d.fields.insert(k.into(), v);
}
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
// The Sprayer has them too: one brush for the eight tools.
let o = app.session.tool_options_of("symbolSprayer");
assert_eq!((o["diameter"].as_f64(), o["intensity"].as_f64(), o["density"].as_f64()), (Some(120.0), Some(9.0), Some(2.0)));
}
}
+2
View File
@@ -234,6 +234,8 @@ fn dialogs_do_not_stretch_to_the_screen() {
("ui.swatchOptions", json!({"name": "White"})),
("ui.colorPicker", json!({})),
("ui.graphicStyleOptions", json!({})),
("ui.brushOptions", json!({"name": "3 pt. Round"})),
("ui.brushOptions", json!({"name": "Bristle Round"})),
("ui.colorGuideOptions", json!({})),
("ui.colorBalanceDialog", json!({})),
("ui.saturateDialog", json!({})),
+5 -6
View File
@@ -26,12 +26,11 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})),
Err(_) => return,
},
// Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog.
"flare" => Dialog::new(
"command",
json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30,
"growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}),
),
// A Flare tool click: the Flare Tool Options, with the tool's values; OK draws a flare there.
"flare" => {
super::flare_options::open(app, Some(vectorcraft_geom::Point::new(x, y)));
return;
}
"rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => {
let mut base = match kind {
"rotate" => json!({"angle": 0}),
+40
View File
@@ -228,6 +228,8 @@
Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos
Ellipse Properties: Propiedades de la elipse:
Invert Pie Invertir sector
Side Length: Longitud del lado:
Make Sides Equal Igualar los lados
Pie End Angle: Ángulo final del sector:
Pie Start Angle: Ángulo inicial del sector:
Show Tool Group Labels Mostrar etiquetas de grupos de herramientas
@@ -988,6 +990,20 @@
Flare Destello
Flare Tool Herramienta Destello
Flare Tool Options Opciones de la herramienta Destello
Halo Halo
Rays Rayos
Rings Anillos
Diameter: Diámetro:
Brightness: Brillo:
Growth: Crecimiento:
Fuzziness: Desenfoque:
Number: Número:
Longest: Más largo:
Path: Trazado:
Largest: Más grande:
Direction: Dirección:
Symbolism Tools Options Opciones de las herramientas de símbolos
Symbol Set Density: Densidad del conjunto de símbolos:
Flat Image Imagen acoplada
Flat Logo Logotipo plano
Flatness: Lisura:
@@ -1545,6 +1561,27 @@
Artboard Mesa de trabajo
Exit Salir
New Bristle Brush Nuevo pincel de cerdas
Calligraphic Brush Options Opciones de pincel caligráfico
Bristle Brush Options Opciones de pincel de cerdas
Roundness: Redondez:
Fixed Fijo
Pressure Presión
Shape: Forma:
Bristle Length: Longitud de cerdas:
Bristle Density: Densidad de cerdas:
Bristle Thickness: Grosor de cerdas:
Paint Opacity: Opacidad de pintura:
Stiffness: Rigidez:
Round Point Punta redonda
Flat Point Punta plana
Round Blunt Romo redondo
Flat Blunt Romo plano
Round Curve Curvo redondo
Flat Curve Curvo plano
Round Angle Sesgado redondo
Flat Angle Sesgado plano
Round Fan Abanico redondo
Flat Fan Abanico plano
New Brush… Nuevo pincel…
New Calligraphic Brush Nuevo pincel caligráfico
New Character Style Nuevo estilo de carácter
@@ -3355,6 +3392,7 @@ axis Both Ambos
@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 closing needs three anchors cerrar necesita tres puntos de ancla
@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
@@ -3421,6 +3459,8 @@ axis Both Ambos
@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 brush selected no hay ningún pincel seleccionado
@msg this kind of brush has no options dialog yet este tipo de pincel aún no tiene un cuadro de opciones
@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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -229,6 +229,8 @@
Auto-Collapse Iconic Panels Réduire automatiquement les panneaux en icônes
Ellipse Properties: Propriétés de l’ellipse :
Invert Pie Inverser le secteur
Side Length: Longueur du côté :
Make Sides Equal Égaliser les côtés
Pie End Angle: Angle de fin du secteur :
Pie Start Angle: Angle de début du secteur :
Show Tool Group Labels Afficher les libellés des groupes d'outils
@@ -987,6 +989,20 @@
Flare Halo
Flare Tool Outil Halo
Flare Tool Options Options de l’outil Halo
Halo Halo
Rays Rayons
Rings Anneaux
Diameter: Diamètre :
Brightness: Luminosité :
Growth: Croissance :
Fuzziness: Flou :
Number: Nombre :
Longest: Le plus long :
Path: Tracé :
Largest: Le plus grand :
Direction: Direction :
Symbolism Tools Options Options des outils de symbole
Symbol Set Density: Densité du jeu de symboles :
Flat Image Image aplatie
Flat Logo Logo en aplats
Flatness: Planéité :
@@ -1542,6 +1558,27 @@
New Art Has Basic Appearance Aspect de base pour les nouvelles illustrations
New Artboard Nouveau plan de travail
New Bristle Brush Nouvelle forme Soies
Calligraphic Brush Options Options de forme calligraphique
Bristle Brush Options Options de forme Soies
Roundness: Arrondi :
Fixed Fixe
Pressure Pression
Shape: Forme :
Bristle Length: Longueur des soies :
Bristle Density: Densité des soies :
Bristle Thickness: Épaisseur des soies :
Paint Opacity: Opacité de la peinture :
Stiffness: Rigidité :
Round Point Pointe ronde
Flat Point Pointe plate
Round Blunt Rond émoussé
Flat Blunt Plat émoussé
Round Curve Rond incurvé
Flat Curve Plat incurvé
Round Angle Rond biseauté
Flat Angle Plat biseauté
Round Fan Éventail rond
Flat Fan Éventail plat
New Brush… Nouvelle forme…
New Calligraphic Brush Nouvelle forme calligraphique
New Character Style Nouveau style de caractère
@@ -3352,6 +3389,7 @@ axis Both Les deux
@msg cannot distort a zero-size bounding box impossible de déformer un cadre de sélection de taille nulle
@msg cannot read PDF: {0} impossible de lire le PDF : {0}
@msg cannot split an open path at its end point impossible de scinder un tracé ouvert à son extrémité
@msg closing needs three anchors la fermeture nécessite trois points d’ancrage
@msg check an asset to export cochez une ressource à exporter
@msg choice must be "merge" or "add", not `{choice}` choice doit valoir "merge" ou "add", pas `{choice}`
@msg choose a direction to divide in choisissez une direction de division
@@ -3418,6 +3456,8 @@ axis Both Les deux
@msg no blend options aucune option de dégradé de formes
@msg no bounds aucune limite
@msg no brush named `{name}` aucune forme nommée `{name}`
@msg no brush selected aucune forme sélectionnée
@msg this kind of brush has no options dialog yet ce type de forme n’a pas encore de boîte de dialogue d’options
@msg no catalog aucun catalogue
@msg no changes since the last save aucune modification depuis le dernier enregistrement
@msg no clock to keep a heartbeat by aucune horloge pour entretenir le signal d’activité
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -228,6 +228,8 @@
Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona
Ellipse Properties: Proprietà dell’ellisse:
Invert Pie Inverti settore
Side Length: Lunghezza del lato:
Make Sides Equal Rendi uguali i lati
Pie End Angle: Angolo finale del settore:
Pie Start Angle: Angolo iniziale del settore:
Show Tool Group Labels Mostra etichette dei gruppi di strumenti
@@ -987,6 +989,20 @@
Flare Riflesso
Flare Tool Strumento Riflesso
Flare Tool Options Opzioni strumento Riflesso
Halo Alone
Rays Raggi
Rings Anelli
Diameter: Diametro:
Brightness: Luminosità:
Growth: Crescita:
Fuzziness: Sfocatura:
Number: Numero:
Longest: Più lungo:
Path: Tracciato:
Largest: Più grande:
Direction: Direzione:
Symbolism Tools Options Opzioni strumenti simboli
Symbol Set Density: Densità del set di simboli:
Flat Image Immagine appiattita
Flat Logo Logo piatto
Flatness: Planarità:
@@ -1545,6 +1561,27 @@
Artboard Tavola da disegno
Exit Esci
New Bristle Brush Nuovo pennello a setole
Calligraphic Brush Options Opzioni pennello calligrafico
Bristle Brush Options Opzioni pennello a setole
Roundness: Rotondità:
Fixed Fisso
Pressure Pressione
Shape: Forma:
Bristle Length: Lunghezza setole:
Bristle Density: Densità setole:
Bristle Thickness: Spessore setole:
Paint Opacity: Opacità colore:
Stiffness: Rigidità:
Round Point Punta tonda
Flat Point Punta piatta
Round Blunt Tonda smussata
Flat Blunt Piatta smussata
Round Curve Tonda curva
Flat Curve Piatta curva
Round Angle Tonda angolata
Flat Angle Piatta angolata
Round Fan Ventaglio tondo
Flat Fan Ventaglio piatto
New Brush… Nuovo pennello…
New Calligraphic Brush Nuovo pennello calligrafico
New Character Style Nuovo stile carattere
@@ -3355,6 +3392,7 @@ axis Both Entrambi
@msg cannot distort a zero-size bounding box impossibile distorcere un riquadro di delimitazione di dimensione zero
@msg cannot read PDF: {0} impossibile leggere il PDF: {0}
@msg cannot split an open path at its end point impossibile dividere un tracciato aperto nel suo punto finale
@msg closing needs three anchors per chiudere servono tre punti di ancoraggio
@msg check an asset to export spunta una risorsa da esportare
@msg choice must be "merge" or "add", not `{choice}` choice deve essere "merge" o "add", non `{choice}`
@msg choose a direction to divide in scegli una direzione in cui dividere
@@ -3421,6 +3459,8 @@ axis Both Entrambi
@msg no blend options nessuna opzione di fusione
@msg no bounds nessun limite
@msg no brush named `{name}` nessun pennello denominato `{name}`
@msg no brush selected nessun pennello selezionato
@msg this kind of brush has no options dialog yet questo tipo di pennello non ha ancora una finestra di opzioni
@msg no catalog nessun catalogo
@msg no changes since the last save nessuna modifica dall'ultimo salvataggio
@msg no clock to keep a heartbeat by nessun orologio con cui mantenere il segnale di attività
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -354,6 +354,8 @@
Fill & Stroke 塗りと線
Fill Color カラー (塗り)
Invert Pie 扇形を反転
Side Length: 辺の長さ:
Make Sides Equal 辺の長さをそろえる
Opacity 不透明度
Pie End Angle: 扇形の終了角度:
Pie Start Angle: 扇形の開始角度:
@@ -1677,6 +1679,27 @@
Artboard アートボード
Exit 終了
New Bristle Brush 新規剛毛ブラシ
Calligraphic Brush Options 絵筆ブラシオプション
Bristle Brush Options 剛毛ブラシオプション
Roundness: 真円率:
Fixed 固定
Pressure 筆圧
Shape: 形状:
Bristle Length: 毛の長さ:
Bristle Density: 毛の密度:
Bristle Thickness: 毛の太さ:
Paint Opacity: 塗料の不透明度:
Stiffness: 硬さ:
Round Point 丸・先細
Flat Point 平・先細
Round Blunt 丸・先丸
Flat Blunt 平・先丸
Round Curve 丸・カーブ
Flat Curve 平・カーブ
Round Angle 丸・斜め
Flat Angle 平・斜め
Round Fan 丸・扇形
Flat Fan 平・扇形
New Brush… 新規ブラシ…
New Calligraphic Brush 新規絵筆ブラシ
New Character Style 新規文字スタイル
@@ -2836,6 +2859,20 @@
Fill: click for swatches, Shift-click for the color mixer 塗り: クリックでスウォッチ、Shift キーを押しながらクリックでカラーミキサー
Fit in View 表示に合わせる
Flare Tool Options フレアツールオプション
Halo ハロー
Rays 光線
Rings リング
Diameter: 直径:
Brightness: 明るさ:
Growth: 拡大:
Fuzziness: ぼかし:
Number: 数:
Longest: 最長:
Path: パス:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options シンボルツールオプション
Symbol Set Density: シンボルセットの密度:
Flattener presets 分割・統合プリセット
Focal Fill 焦点の塗り
Focal Stroke 焦点の線
@@ -3237,6 +3274,8 @@ theme Appearance 外観
@msg Show All Hidden Attributes: nothing is hidden 非表示の属性をすべて表示: 非表示の属性はありません
@msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません
@msg no brush named `{name}` `{name}` という名前のブラシが見つかりません
@msg no brush selected ブラシが選択されていません
@msg this kind of brush has no options dialog yet この種類のブラシにはまだオプションダイアログがありません
@msg no symbol instances selected シンボルインスタンスが選択されていません
@msg select a symbol instance シンボルインスタンスを選択してください
@msg the art has no bounds アートに境界がありません
@@ -3279,6 +3318,7 @@ theme Appearance 外観
@msg no saved selections 保存した選択範囲はありません
@msg no such artboard 指定されたアートボードが見つかりません
@msg cannot split an open path at its end point オープンパスは端点で分割できません
@msg closing needs three anchors 閉じるには 3 つのアンカーポイントが必要です
@msg empty brush stroke ブラシストロークが空です
@msg no such anchor 指定されたアンカーポイントが見つかりません
@msg no such segment 指定されたセグメントが見つかりません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -226,6 +226,8 @@
Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей
Ellipse Properties: Свойства эллипса:
Invert Pie Инвертировать сектор
Side Length: Длина стороны:
Make Sides Equal Выровнять стороны
Pie End Angle: Конечный угол сектора:
Pie Start Angle: Начальный угол сектора:
Show Tool Group Labels Показывать названия групп инструментов
@@ -985,6 +987,20 @@
Flare Блик
Flare Tool Инструмент «Блик»
Flare Tool Options Параметры инструмента «Блик»
Halo Ореол
Rays Лучи
Rings Кольца
Diameter: Диаметр:
Brightness: Яркость:
Growth: Рост:
Fuzziness: Размытие:
Number: Количество:
Longest: Самый длинный:
Path: Контур:
Largest: Самое большое:
Direction: Направление:
Symbolism Tools Options Параметры инструментов символов
Symbol Set Density: Плотность набора символов:
Flat Image Плоское изображение
Flat Logo Плоский логотип
Flatness: Плоскостность:
@@ -1541,6 +1557,27 @@
New Art Has Basic Appearance Новый рисунок имеет основное оформление
New Artboard Новая монтажная область
New Bristle Brush Новая щетинная кисть
Calligraphic Brush Options Параметры каллиграфической кисти
Bristle Brush Options Параметры щетинной кисти
Roundness: Округлость:
Fixed Фиксированный
Pressure Нажим
Shape: Форма:
Bristle Length: Длина щетинок:
Bristle Density: Плотность щетинок:
Bristle Thickness: Толщина щетинок:
Paint Opacity: Непрозрачность краски:
Stiffness: Жёсткость:
Round Point Круглая заострённая
Flat Point Плоская заострённая
Round Blunt Круглая тупая
Flat Blunt Плоская тупая
Round Curve Круглая изогнутая
Flat Curve Плоская изогнутая
Round Angle Круглая скошенная
Flat Angle Плоская скошенная
Round Fan Круглая веерная
Flat Fan Плоская веерная
New Brush… Новая кисть…
New Calligraphic Brush Новая каллиграфическая кисть
New Character Style Новый стиль символа
@@ -3351,6 +3388,7 @@ axis Both Оба
@msg cannot distort a zero-size bounding box нельзя исказить ограничительную рамку нулевого размера
@msg cannot read PDF: {0} не удалось прочитать PDF: {0}
@msg cannot split an open path at its end point нельзя разделить открытый контур в его конечной точке
@msg closing needs three anchors для замыкания нужны три опорные точки
@msg check an asset to export отметьте ресурс для экспорта
@msg choice must be "merge" or "add", not `{choice}` choice должно быть "merge" или "add", а не `{choice}`
@msg choose a direction to divide in выберите направление для разделения
@@ -3417,6 +3455,8 @@ axis Both Оба
@msg no blend options нет параметров перехода
@msg no bounds нет границ
@msg no brush named `{name}` нет кисти с именем `{name}`
@msg no brush selected кисть не выбрана
@msg this kind of brush has no options dialog yet у кисти этого типа пока нет окна параметров
@msg no catalog нет каталога
@msg no changes since the last save нет изменений с момента последнего сохранения
@msg no clock to keep a heartbeat by нет часов для поддержания сигнала
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -222,6 +222,8 @@
Auto-Collapse Iconic Panels Автоматично згортати значки панелей
Ellipse Properties: Властивості еліпса:
Invert Pie Інвертувати сектор
Side Length: Довжина сторони:
Make Sides Equal Вирівняти сторони
Pie End Angle: Кінцевий кут сектора:
Pie Start Angle: Початковий кут сектора:
Show Tool Group Labels Показувати назви груп інструментів
@@ -981,6 +983,20 @@
Flare Відблиск
Flare Tool Інструмент «Відблиск»
Flare Tool Options Параметри інструменту «Відблиск»
Halo Ореол
Rays Промені
Rings Кільця
Diameter: Діаметр:
Brightness: Яскравість:
Growth: Зростання:
Fuzziness: Розмиття:
Number: Кількість:
Longest: Найдовший:
Path: Контур:
Largest: Найбільше:
Direction: Напрямок:
Symbolism Tools Options Параметри інструментів символів
Symbol Set Density: Щільність набору символів:
Flat Image Плоске зображення
Flat Logo Плоский логотип
Flatness: Площинність:
@@ -1537,6 +1553,27 @@
New Art Has Basic Appearance Новий малюнок має основне оформлення
New Artboard Нова монтажна область
New Bristle Brush Новий щетинний пензель
Calligraphic Brush Options Параметри каліграфічного пензля
Bristle Brush Options Параметри щетинного пензля
Roundness: Округлість:
Fixed Фіксований
Pressure Натиск
Shape: Форма:
Bristle Length: Довжина щетинок:
Bristle Density: Щільність щетинок:
Bristle Thickness: Товщина щетинок:
Paint Opacity: Непрозорість фарби:
Stiffness: Жорсткість:
Round Point Кругла загострена
Flat Point Пласка загострена
Round Blunt Кругла тупа
Flat Blunt Пласка тупа
Round Curve Кругла вигнута
Flat Curve Пласка вигнута
Round Angle Кругла скошена
Flat Angle Пласка скошена
Round Fan Кругла віялова
Flat Fan Пласка віялова
New Brush… Новий пензель…
New Calligraphic Brush Новий каліграфічний пензель
New Character Style Новий стиль символу
@@ -3338,6 +3375,7 @@ axis Both Обидві
@msg cannot distort a zero-size bounding box не можна спотворити обмежувальну рамку нульового розміру
@msg cannot read PDF: {0} не вдалося прочитати PDF: {0}
@msg cannot split an open path at its end point не можна розділити відкритий контур у його кінцевій точці
@msg closing needs three anchors для замикання потрібні три опорні точки
@msg check an asset to export позначте ресурс для експорту
@msg choice must be "merge" or "add", not `{choice}` choice має бути "merge" або "add", а не `{choice}`
@msg choose a direction to divide in виберіть напрямок для поділу
@@ -3404,6 +3442,8 @@ axis Both Обидві
@msg no blend options немає параметрів переходу
@msg no bounds немає кордонів
@msg no brush named `{name}` немає пензля з ім’ям `{name}`
@msg no brush selected пензель не вибрано
@msg this kind of brush has no options dialog yet пензель цього типу ще не має вікна параметрів
@msg no catalog немає каталогу
@msg no changes since the last save немає змін з моменту останнього збереження
@msg no clock to keep a heartbeat by немає годинника для підтримки сигналу
Can't render this file because it contains an unexpected character in line 634 and column 40.
+37
View File
@@ -211,6 +211,8 @@
Auto-Collapse Iconic Panels 自动折叠图标面板
Ellipse Properties: 椭圆属性:
Invert Pie 反转扇形
Side Length: 边长:
Make Sides Equal 使各边等长
Pie End Angle: 扇形终止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 显示工具组标签
@@ -965,6 +967,20 @@
Flare 光晕
Flare Tool 光晕工具
Flare Tool Options 光晕工具选项
Halo 光晕
Rays 射线
Rings 光环
Diameter: 直径:
Brightness: 亮度:
Growth: 增大:
Fuzziness: 模糊度:
Number: 数量:
Longest: 最长:
Path: 路径:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options 符号工具选项
Symbol Set Density: 符号组密度:
Flat Image 平面图像
Flatness: 平滑度:
Flatten Transparency 拼合透明度
@@ -1501,6 +1517,27 @@
New Artboard 新建画板
Exit 退出
New Bristle Brush 新建鬃毛画笔
Calligraphic Brush Options 书法画笔选项
Bristle Brush Options 鬃毛画笔选项
Roundness: 圆度:
Fixed 固定
Pressure 压力
Shape: 形状:
Bristle Length: 鬃毛长度:
Bristle Density: 鬃毛密度:
Bristle Thickness: 鬃毛粗细:
Paint Opacity: 上色不透明度:
Stiffness: 硬度:
Round Point 圆尖头
Flat Point 扁尖头
Round Blunt 圆钝头
Flat Blunt 扁钝头
Round Curve 圆弯头
Flat Curve 扁弯头
Round Angle 圆斜头
Flat Angle 扁斜头
Round Fan 圆扇头
Flat Fan 扁扇头
New Brush… 新建画笔…
New Calligraphic Brush 新建书法画笔
New Character Style 新建字符样式
Can't render this file because it contains an unexpected character in line 2 and column 73.
+37
View File
@@ -222,6 +222,8 @@
Auto-Collapse Iconic Panels 自動收合圖示面板
Ellipse Properties: 橢圓屬性:
Invert Pie 反轉扇形
Side Length: 邊長:
Make Sides Equal 使各邊等長
Pie End Angle: 扇形終止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 顯示工具組標籤
@@ -988,6 +990,20 @@
Flare 光暈
Flare Tool 光暈工具
Flare Tool Options 光暈工具選項
Halo 光暈
Rays 射線
Rings 光環
Diameter: 直徑:
Brightness: 亮度:
Growth: 增大:
Fuzziness: 模糊度:
Number: 數量:
Longest: 最長:
Path: 路徑:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options 符號工具選項
Symbol Set Density: 符號組密度:
Flat Image 平面影像
Flatness: 平滑度:
Flatten Artwork 將圖稿平面化
@@ -1545,6 +1561,27 @@
Artboard 工作區域
Exit 結束
New Bristle Brush 新增鬃毛筆刷
Calligraphic Brush Options 書法筆刷選項
Bristle Brush Options 鬃毛筆刷選項
Roundness: 圓度:
Fixed 固定
Pressure 壓力
Shape: 形狀:
Bristle Length: 鬃毛長度:
Bristle Density: 鬃毛密度:
Bristle Thickness: 鬃毛粗細:
Paint Opacity: 上色不透明度:
Stiffness: 硬度:
Round Point 圓尖頭
Flat Point 扁尖頭
Round Blunt 圓鈍頭
Flat Blunt 扁鈍頭
Round Curve 圓彎頭
Flat Curve 扁彎頭
Round Angle 圓斜頭
Flat Angle 扁斜頭
Round Fan 圓扇頭
Flat Fan 扁扇頭
New Brush… 新增筆刷…
New Calligraphic Brush 新增書法筆刷
New Character Style 新增字元樣式
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20 -8
View File
@@ -337,6 +337,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
),
(
"ui.brushOptions",
"Brush Options…",
"",
"{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens: Calligraphic (dialog `brushOptions`, fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation) or Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness); OK runs brush.options. Other brush types: error",
),
(
"ui.graphicStyleOptions",
"Graphic Style Options…",
@@ -347,7 +353,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"tool.options",
"Tool Options…",
"",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values})",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height; OK runs artboard.setProps), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values})",
),
(
"ui.colorGuideOptions",
@@ -377,7 +383,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.swatchLibrary.other",
"Other Library…",
"",
"{path?} (default: pick a file) load a .vcswatches, .gpl or .ase library, or another document's swatches (engine: swatch.library.load), and open it in the library panel",
"{path?} (default: pick a file) load a .vcswatches, .gpl, .ase or .acb (color book) library, or another document's swatches (engine: swatch.library.load), and open it in the library panel",
),
(
"ui.saveSwatchLibrary",
@@ -1206,6 +1212,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
"ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()),
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
"ui.colorGuideOptions" => {
crate::dialogs::color_guide_options::open(app);
@@ -1871,6 +1878,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().is_some(),
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveSwatchLibrary" => app.session.active().is_some(),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
@@ -2956,6 +2964,14 @@ fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values
/// (the command's query).
pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?;
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" }))
}
pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
@@ -2999,12 +3015,8 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
// Object → Graph → Type… / Data…: dialogs with the selected graph's current values.
if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(v) => {
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
if let Err(e) = graph_dialog(app, id) {
app.status(e);
}
return;
}
+74 -10
View File
@@ -1,14 +1,16 @@
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
//! applies it to the selected paths (and makes it the Paintbrush's current brush).
//! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking
//! one opens its Brush Options.
use std::collections::HashMap;
use egui::{Sense, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, Document, Node, color::Color, color::Paint};
use vectorcraft_doc::{Appearance, Document, Node, PressureProfile, color::Color, color::Paint};
use super::{first_selected, pstate, set_pstate};
use crate::VectorcraftApp;
use crate::dialogs::brush_options;
use crate::theme::Tokens;
use crate::widgets::{self, PanelDrag, menu_item};
@@ -26,14 +28,17 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<Strin
}
/// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's
/// JSON.
/// JSON. The stroke goes from light to heavy pen pressure and back, so pressure-sensitive brushes
/// show how they vary.
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| {
let mut doc = Document::new(w, h);
doc.unknown.insert("brushes".into(), json!([def]));
let name = def["name"].as_str()?.to_string();
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut()?.brush = Some(name);
let st = ap.stroke_mut()?;
st.brush = Some(name);
st.pressure = Some(PressureProfile::sample());
// A gentle S-curve across the swatch.
let mut bp = vectorcraft_geom::BezPath::new();
let (x0, x1) = (h * 0.5, w - h * 0.5);
@@ -68,6 +73,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let defs: HashMap<String, Value> =
list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect();
let mut clicked: Option<String> = None;
let mut options: Option<String> = None;
let list_rect = widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
ui.set_width(ui.available_width());
@@ -83,6 +89,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if group.is_empty() {
continue;
}
let has_options = brush_options::TYPES.contains(&ty);
let row = |ui: &mut Ui, name: &str, size: egui::Vec2, label: bool| -> egui::Response {
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
// Dragged onto a path, the brush is applied to it.
@@ -112,18 +119,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
resp.on_hover_text(name)
};
// A click applies the brush; the second click of a double-click opens its options.
let mut pick = |resp: egui::Response, name: &String| {
if resp.double_clicked() && has_options {
options = Some(name.clone());
} else if resp.clicked() {
clicked = Some(name.clone());
}
};
if list_view {
for (name, _) in group {
if row(ui, name, vec2(ui.available_width(), 26.0), true).clicked() {
clicked = Some(name.clone());
}
pick(row(ui, name, vec2(ui.available_width(), 26.0), true), name);
}
} else {
ui.horizontal_wrapped(|ui| {
for (name, _) in group {
if row(ui, name, vec2(64.0, 28.0), false).clicked() {
clicked = Some(name.clone());
}
pick(row(ui, name, vec2(64.0, 28.0), false), name);
}
});
}
@@ -146,6 +157,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("brush.setCurrent", json!({"name": name})).ok();
}
}
if let Some(name) = options
&& let Err(e) = app.run("ui.brushOptions", json!({ "name": name }))
{
app.status(e);
}
let has_brush = sel_brush.is_some();
let target = sel_brush.clone().or(current.clone());
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
@@ -204,6 +220,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Expand Brush Strokes"), sel.is_some(), false) {
app.run("object.expandBrush", json!({})).ok();
}
// On the selected path's brush, else the current one, as the other items.
if menu_item(ui, tl!("Brush Options…"), brush_options::available(app), false)
&& let Err(e) = app.run("ui.brushOptions", json!({}))
{
app.status(e);
}
ui.separator();
let mut hidden: Vec<String> = pstate(ui.ctx(), "br-hidden");
for (ty, label) in KINDS {
@@ -246,6 +268,48 @@ mod tests {
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes
/// painted with it). A single click only applies the brush.
#[test]
fn double_clicking_a_brush_opens_its_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
let clicks = |at: Pos2, n: usize| -> Vec<egui::Event> {
std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect()
};
let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let raw = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))),
time: Some(time),
events,
..Default::default()
};
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
};
at_time(&mut app, 0.0, vec![]);
let (list, _) = brushes(&mut app);
let first = list[0].0.clone();
assert_eq!(list[0].1, "calligraphic");
// The first tile, clicked once: it becomes current, no dialog.
let tile = pos2(36.0, 16.0);
at_time(&mut app, 1.0, clicks(tile, 1));
assert!(app.ui.dialog.is_none());
assert_eq!(brushes(&mut app).1.as_deref(), Some(first.as_str()));
// Double-clicked: Calligraphic Brush Options on that brush.
at_time(&mut app, 3.0, clicks(tile, 2));
let d = app.ui.dialog.as_mut().expect("Brush Options opened");
assert_eq!((d.kind.as_str(), d.str("name"), d.str("sizeMode")), (crate::dialogs::brush_options::KIND, first.clone(), "fixed".into()));
d.fields.insert("sizeMode".into(), json!("pressure"));
d.fields.insert("sizeVariation".into(), json!(2));
d.fields.insert("name".into(), json!("Pressure Round"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let def = app.run("brush.get", json!({"name": "Pressure Round"})).unwrap();
assert_eq!((def["modes"].clone(), def["variation"][2].clone()), (json!(["fixed", "fixed", "pressure"]), json!(2.0)));
assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current");
}
#[test]
fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+17 -8
View File
@@ -48,6 +48,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{LiveCorners, Node, StrokeLayer};
use vectorcraft_engine::inspect::StrokeMixed;
use vectorcraft_geom::shapes::CornerKind;
use crate::theme::Tokens;
use crate::widgets::{Live, dim_label};
@@ -61,13 +62,15 @@ pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
/// The radius the Live Corners of path `n` show in the panels: that of the corners the panels set
/// (the Direct-Selected ones, else every corner), blank when they differ.
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node) -> Option<f64> {
corner_style(app, n).0
}
/// The radius and kind of the corners [`corner_radius`] reads, each None when they differ.
pub(crate) fn corner_style(app: &VectorcraftApp, n: &Node) -> (Option<f64>, Option<CornerKind>) {
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
let corners = LiveCorners::of(n)?;
corners.style(&corners.picked(partial)).0
LiveCorners::of(n).map_or((None, None), |c| c.style(&c.picked(partial)))
}
/// The Corner Radius field of a live rectangle's or polygon's properties: [`corner_radius`],
/// which a new value sets on those corners.
/// The width of a panel's number fields: the Transform fields', two to a row beside the reference
/// point, their labels and the W/H link, so every field in the panel is as wide (#696). Measured
/// from the full row, before its widgets.
@@ -77,17 +80,23 @@ pub(crate) fn field_width(ui: &Ui) -> f32 {
((ui.available_width() - AROUND) / 2.0).clamp(60.0, 110.0)
}
/// The Corner Radius field of a live rectangle's properties: [`corner_radius`], which a new value
/// sets on those corners.
pub(crate) fn corner_radius_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str) {
let units = app.session.general_unit();
let fw = field_width(ui);
ui.horizontal(|ui| {
dim_label(ui, tl!("Corner Radius:"));
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, fw) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
corner_radius_field(app, ui, n, id, fw);
});
}
/// [`corner_radius_row`]'s field, `width` wide.
pub(crate) fn corner_radius_field(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: impl std::hash::Hash + std::fmt::Debug, width: f32) {
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), app.session.general_unit(), width) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
}
/// Number of selected objects.
pub(crate) fn selection_len(app: &VectorcraftApp) -> usize {
app.session.active().map(|d| d.selection.len()).unwrap_or(0)
+1 -10
View File
@@ -447,16 +447,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
{
match live {
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
let fw = super::field_width(ui);
ui.horizontal(|ui| {
dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, fw) {
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
}
});
corner_radius_row(app, ui, &n, "radius");
}
vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"),
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"),
_ => {}
}
+80 -9
View File
@@ -4,10 +4,12 @@
use egui::Ui;
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_doc::{LiveShape, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_geom::shapes::CornerKind;
use super::{corner_radius_row, first_selected, pstate, set_pstate};
use crate::dialogs::corners::kind_label as corner_kind;
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
@@ -182,15 +184,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::subheader(ui, tl!("Rectangle Properties:"));
corner_radius_row(app, ui, &n, "xfp-radius");
}
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
vectorcraft_doc::LiveShape::Polygon { .. } => {
widgets::subheader(ui, tl!("Polygon Properties:"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, "xfp-sides", *sides as f64, "", 0, 60.0) {
app.run("object.setLiveShape", json!({"sides": s.clamp(3.0, 20.0) as u32})).ok();
}
});
corner_radius_row(app, ui, &n, "xfp-radius");
polygon_rows(app, ui, &n, live, "xfp-polygon");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => {
widgets::subheader(ui, tl!("Ellipse Properties:"));
@@ -231,6 +227,56 @@ pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), i
}
}
/// A live polygon's Polygon Properties (Transform and Properties panels): its side count, angle
/// (counterclockwise), corner type and radius, radius (centre to vertex) and side length, and Make
/// Sides Equal, which an uneven scale enables.
pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live: &LiveShape, id: &str) {
let LiveShape::Polygon { sides, .. } = live else { return };
let (units, fw) = (app.session.general_unit(), super::field_width(ui));
let radius = live.polygon_radius();
let side = radius.map(|r| 2.0 * r * (std::f64::consts::PI / f64::from((*sides).max(3))).sin());
let kind = super::corner_style(app, n).1;
let kinds: Vec<&str> = CornerKind::ALL.iter().map(|k| corner_kind(*k)).collect();
// The `object.setLiveShape` param an edited field sets.
let mut set = None;
egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| {
widgets::dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, (id, "sides"), f64::from(*sides), "", 0, fw) {
set = Some(("sides", json!(s.round().max(3.0) as u64)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Angle:"));
if let Some(a) = widgets::plain_field(ui, (id, "angle"), live.polygon_angle().unwrap_or(0.0), "°", 1, fw) {
set = Some(("polygonAngle", json!(a)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Corner:"));
if let Some(k) = widgets::dropdown_names(ui, (id, "kind"), kind.map_or("", corner_kind), &kinds, fw).and_then(|i| CornerKind::ALL.get(i)) {
set = Some(("kind", json!(k)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Corner Radius:"));
super::corner_radius_field(app, ui, n, (id, "cornerRadius"), fw);
ui.end_row();
widgets::dim_label(ui, tl!("Radius:"));
if let Some(r) = widgets::num_field(ui, (id, "radius"), radius, units, fw) {
set = Some(("polygonRadius", json!(r)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Side Length:"));
if let Some(l) = widgets::num_field(ui, (id, "side"), side, units, fw) {
set = Some(("sideLength", json!(l)));
}
ui.end_row();
});
if ui.add_enabled_ui(!live.polygon_sides_equal(), |ui| widgets::flat_button(ui, tl!("Make Sides Equal"), 130.0)).inner.clicked() {
set = Some(("makeSidesEqual", json!(true)));
}
if let Some((key, v)) = set {
app.run("object.setLiveShape", json!({ key: v })).ok();
}
}
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
/// popover under it, as the Stroke link does with the Stroke panel.
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -335,6 +381,31 @@ mod tests {
assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}");
}
/// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the
/// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch.
#[test]
fn a_polygon_shows_its_polygon_properties() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap();
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
let shown = texts(&mut app, draw);
// A hexagon's side is as long as its radius.
for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] {
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
}
}
assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:"));
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind {
NodeKind::Path { live: Some(l), .. } => l.clone(),
k => panic!("{k:?}"),
};
assert!(!live(&app).polygon_sides_equal());
app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap();
assert!(live(&app).polygon_sides_equal());
}
#[test]
fn the_artboard_tool_shows_the_active_artboard() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
+4 -3
View File
@@ -2,9 +2,10 @@
//!
//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…)
//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it
//! closes. On Windows and macOS the dialog runs the window's events meanwhile; on Linux nothing
//! does, so a Wayland compositor finds the window not answering and offers to kill it. There the
//! host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
//! closes. On Windows the dialog runs the window's events meanwhile; on Linux nothing does, so a
//! Wayland compositor finds the window not answering and offers to kill it, and on macOS they
//! re-enter the window's event handler, which crashed the app when the panel was resized (#867).
//! There the host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
//! and when the dialog answers, what asked (the command, the dialog's OK, a panel's button: the
//! *entry*) runs again and gets the picked path at once.
+42 -2
View File
@@ -312,8 +312,10 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::menus::invoke(app, &id, json!({}));
Ok(json!({ "dialog": dialog }))
}
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
// The Gradient and Magic Wand tools: their panels.
"gradient" | "magicWand" if app.ui.open_panel.as_deref() == Some(tool) => Ok(json!({ "open": tool })),
"gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })),
"artboard" => crate::dialogs::artboard_options::open(app),
"eyedropper" => {
crate::dialogs::eyedropper::open(app);
Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND }))
@@ -326,12 +328,18 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::dialogs::perspective_options::open(app);
Ok(json!({ "dialog": crate::dialogs::perspective_options::KIND }))
}
// The Flare tool: its options, which draw the next flare (OK draws none).
"flare" => Ok(crate::dialogs::flare_options::open(app, None)),
// A double click on the Print Tiling tool puts the pages back where the placement puts them.
"printTiling" => app.run("print.tiling.set", json!({ "reset": true })),
// The Liquify tools: their Tool Options (the Global Brush Dimensions and the tool's own).
_ if vectorcraft_tools::settings::LIQUIFY.contains(&tool) => crate::dialogs::liquify::open(app, tool),
// The freehand tools: their Tool Options (Fidelity, fill, the tolerances, the brush size).
_ if crate::dialogs::freehand::TOOLS.contains(&tool) => crate::dialogs::freehand::open(app, tool),
// The Symbolism tools: the brush they share.
_ if vectorcraft_tools::settings::SYMBOLISM.contains(&tool) => Ok(crate::dialogs::symbolism_options::open(app, tool)),
// The graph tools: Graph Type for the selected graph.
_ if vectorcraft_tools::extra::is_graph_tool(tool) => crate::menus::graph_dialog(app, "graph.setType"),
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
_ => Err(format!("the {tool} tool has no options")),
}
@@ -815,6 +823,38 @@ pub(crate) mod tests {
assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]);
}
/// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a
/// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel.
#[test]
fn more_tools_open_their_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
// Without the Artboard tool, the window's artboard.
assert_eq!(open_options(&mut app, "artboard").unwrap()["dialog"], "artboardOptions");
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.f64("index", 9.0), d.f64("width", 0.0), d.str("name")), (0.0, 300.0, "Artboard 1".to_string()));
// With it, its active artboard.
app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 50})).unwrap();
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
app.run("tool.setOption", json!({"key": "active", "value": 1})).unwrap();
open_options(&mut app, "artboard").unwrap();
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.f64("index", 9.0), d.f64("x", 0.0), d.f64("width", 0.0)), (1.0, 400.0, 100.0));
// Graph Type needs a selected graph.
app.run("select.set", json!({"ids": []})).unwrap();
assert!(open_options(&mut app, "pieGraph").is_err());
assert!(app.ui.dialog.is_none());
app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap();
assert_eq!(open_options(&mut app, "pieGraph").unwrap()["dialog"], "command");
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.str("__command"), d.str("type")), ("graph.setType".to_string(), "column".to_string()));
// The Magic Wand panel opens, and stays open on a second double-click.
open_options(&mut app, "magicWand").unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("magicWand"));
assert_eq!(open_options(&mut app, "magicWand").unwrap()["open"], "magicWand");
assert_eq!(app.ui.open_panel.as_deref(), Some("magicWand"));
}
/// A double-click at `at`, `time` seconds in (a second apart from the last).
fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
+49 -1
View File
@@ -1230,6 +1230,26 @@ the path it had begun while that stays selected. Its reply's `tool` is the tool
{"name":"run_command","arguments":{"command":"path.moveAnchors","params":{"dx":0,"dy":40}}}
```
## Editing a path with the Curvature tool
The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798). Each edit
is one undo step and leaves the anchor it edited direct-selected (the tool's current point):
`path.curvatureEdit {id, subpath?, op, …}` answers `{anchor}`. `op: "move"` with `anchor`, `x`, `y` moves a point and
re-curves only its two segments: a smooth point turns along the line through its neighbours, and the neighbours'
handles keep their directions (their lengths scale with the chord), so the segments beyond don't change.
`op: "insert"` with `segment`, `t` adds a smooth point there without changing the shape (with `x`, `y` it is then
moved there: the tool's click-and-drag on a segment). `op: "extend"` with `x`, `y` goes on from the `end` (default)
or the `start` of an open subpath, and `op: "close"` closes it from that end; `from` says how the old end bends into
the new segment: `"keep"` (default) keeps its curve, `"smooth"` curves it through its neighbours (a point the tool
placed), `"corner"` leaves it without handles. Alt-click or double-click toggles a point with `path.convertAnchor`, and
Backspace/Delete removes the current point with `path.removeAnchor`.
```json
{"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"move","anchor":2,"x":260,"y":140}}}
{"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"extend","end":"end","x":550,"y":120}}}
{"name":"run_command","arguments":{"command":"path.curvatureEdit","params":{"id":12,"op":"extend","x":650,"y":60,"from":"smooth"}}}
```
## Registration and trim marks
Every document has the built-in `[Registration]` swatch (listed after None by `swatch.list`): a colour that prints on
@@ -1320,6 +1340,19 @@ dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"radius":5}}}
```
The same command sets the rest of a live shape's properties, as the Properties and Transform panels show them. A
polygon (Polygon Properties): `sides` (3–1000), `polygonAngle` (degrees counterclockwise, its first vertex straight up
at 0), `polygonRadius` (pt, centre to vertex), `sideLength` (pt, which sets the radius) and `makeSidesEqual: true`,
which drops an uneven scale or shear (its sides then differ) and keeps its centre, angle and mean radius; a radius or
length that isn't a number above 0 is an error. An ellipse (Ellipse Properties): `pieStart` and `pieEnd` (degrees
counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse) and `invertPie: true`, which swaps them. Each call is
one undo step.
```json
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"sides":8,"polygonAngle":22.5,"sideLength":40}}}
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"makeSidesEqual":true}}}
```
## Use Preview Bounds
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel
@@ -2652,13 +2685,28 @@ active tool, `tool` names another one, and `{}` just reads them; the result is t
tool keeps last across tool switches (and documents) and are saved with the preferences (`toolSettings` in
`prefs.get`'s full object), as the reference app keeps them: the Liquify tools' brush and tool options, Mirror & Cut's
axis and side, Puppet Warp's mesh, the Symbolism brush, the line, grid, pencil, brush and eraser tools' options,
polygon sides and star points. The Liquify tools share one set of Global Brush Dimensions (width, height, angle,
polygon sides and star points, and the Flare Tool Options. The Liquify tools share one set of Global Brush Dimensions (width, height, angle,
intensity), and the Symbolism tools one brush. Interaction state (pins, a reference point) starts afresh.
```json
{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"twirl","values":{"width":60,"rate":90}}}}
```
`tool.options {tool}` opens what double-clicking the tool's button opens (its params doc lists every tool), in the
desktop app. The Flare tool's are its Flare Tool Options (dialog `flareOptions`): `diameter` and `pathLength` (pt),
`opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and `largest` (%), `rays`, `rings`,
`direction` (°), and `raysOn` and `ringsOn` (the Rays and Rings checkboxes; off, the flare has none). OK keeps them
(`tool.setOption {tool: "flare", values}`) and the next flare dragged out uses them; a click with the tool opens the
same dialog, and its OK also draws a flare there. The Artboard tool's are the active artboard's Artboard Options
(dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's), a graph tool's
the selected graph's Graph Type (an error without one), a Symbolism tool's the Symbolism Tools Options (dialog
`symbolismOptions`: `diameter` in points, `intensity` and `density` 1–10, one brush for the eight tools) and the
Magic Wand tool's its panel.
```json
{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"flare","values":{"rays":24,"ringsOn":false}}}}
```
## Perspective grid
The grid is document data (`perspective.grid.set` edits the model directly). `perspective.grid.get` reads it: