Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 4eac7b505f Fix #506: drawing tools snap to Smart Guides as the Selection tool does
Every drawing tool now asks one snapper (guides::DrawSnap) for the points it
places: 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 and a new artboard's corners. Points land on other objects'
anchors, centres and paths ("anchor", "center", "path") or line up with their
anchors, edges and centres ("align"); hovering shows it before the press, the
Pen's guides end its rubber band, and the Pen lands on the path being drawn
too. Shift keeps a point to its 45-degree step (from Constrain Angle) or a
box's diagonal and slides it along that way into line with a target.
Preferences > Smart Guides > Construction Guides and Angles now act: a drawn
point near a guide through the point it leaves from lands on it.

Before, the Pen snapped only its press and showed nothing, and the shape tools
re-gathered targets on every drag step from the document showing their own
preview, so the dragged corner stuck to where the last step left it.

Performance: targets are gathered once per document state (keyed on the
document's uid and revision, now in ToolContext), from the art in the window,
and sorted, so the anchors, centres and lines near the pointer are found by
bisection. Leaving objects out of the targets no longer looks up each node's
ancestry (quadratic in the document's size): one pruned walk does it, which
also speeds up the Selection tool's moves and resizes.

The direction handles' HandleSnap (#494) is now a DrawSnap holding its own
targets (Targets::for_handle) for the drag: one cache, one lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:20:27 +07:00
16 changed files with 1061 additions and 162 deletions

No files matched your search

+6 -6
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), 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 files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, 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 window (#493), panels, panel groups and the Tools panel undocking intLine truncated
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), 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 files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, 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 window (#493), panels, panel groups and the Tools panel undocking intLine truncated
## Where we are
@@ -107,7 +107,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 32 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 30 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Spacing Guides, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
@@ -117,7 +117,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
- **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.
- **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. 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).
- **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).
@@ -131,7 +131,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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).
- **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.
- **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"); 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), 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), and Draw Normal / Behind / Inside modes.
- **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"); 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), 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).
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
@@ -213,12 +213,12 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image) (Vector Halftone is 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 presets/rearrange polish (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 | 82% | global/video rulers, smart-guide preference depth, 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) | 3–5 |
| Guides, grids, smart guides, snapping, rulers | 3 | 84% | global/video rulers, Spacing Guides, 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) | 2–4 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs 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 remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| 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 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 32 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
+3 -1
View File
@@ -634,7 +634,7 @@ impl Default for Prefs {
object_highlighting: true,
transform_tools_guides: true,
construction_guides: true,
construction_angles: s("90° & 45° Angles"),
construction_angles: s(vectorcraft_tools::guides::DEFAULT_CONSTRUCTION_ANGLES),
anchor_path_labels: true,
measurement_labels: true,
spacing_guides: true,
@@ -1477,6 +1477,8 @@ mod tests_scalestrokes;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartguides;
#[cfg(test)]
mod tests_strokegeom;
#[cfg(test)]
mod tests_strokegradient;
+109
View File
@@ -0,0 +1,109 @@
//! Smart Guides for the drawing tools, driven through the session (#506): where the Rectangle
//! tool's corners and the Pen's anchors land, and the guides they show.
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
use super::*;
use crate::tooling::ViewInfo;
/// An 800 × 600 document with a 100 × 100 square at (100, 100), nothing selected.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s
}
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
}
fn labels(s: &mut Session) -> Vec<(String, Point)> {
s.overlays(ViewInfo::default()).into_iter().filter_map(|o| if let Overlay::Label { text, p, .. } = o { Some((text, p)) } else { None }).collect()
}
/// The bounds of the object the last gesture made (it is selected).
fn made(s: &Session) -> Rect {
let d = s.doc().unwrap();
let id: NodeId = d.selection.objects[0];
d.doc.node(id).unwrap().geometric_bounds().unwrap()
}
#[test]
fn a_rectangle_corner_lands_on_another_objects_anchor() {
let mut s = session();
s.select_tool("rectangle", ViewInfo::default()).unwrap();
// Hovering near the square's corner already says where the press goes.
pointer(&mut s, PointerKind::Move, 302.0, 297.0);
pointer(&mut s, PointerKind::Move, 202.0, 203.0);
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
pointer(&mut s, PointerKind::Down, 302.0, 297.0);
pointer(&mut s, PointerKind::Drag, 250.0, 250.0);
pointer(&mut s, PointerKind::Drag, 202.0, 203.0);
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
let measure = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Measure { text, .. } = o { Some(text) } else { None });
assert!(measure.as_deref().is_some_and(|t| t.starts_with("W: 102") && t.contains("H: 100")), "{measure:?}");
pointer(&mut s, PointerKind::Up, 202.0, 203.0);
// The start lined up with the artboard's centre (y = 300), the corner on the anchor.
assert_eq!(made(&s), Rect::new(200.0, 200.0, 302.0, 300.0));
}
/// The corner being dragged never snaps to the rectangle's own preview: dragged slowly, it follows
/// the pointer instead of sticking where the last step left it.
#[test]
fn a_rectangle_does_not_snap_to_itself_while_drawn() {
let mut s = session();
s.select_tool("rectangle", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 320.0, 420.0);
for x in 370..=380 {
pointer(&mut s, PointerKind::Drag, f64::from(x), 470.0 + f64::from(x - 370));
}
pointer(&mut s, PointerKind::Up, 380.0, 480.0);
assert_eq!(made(&s), Rect::new(320.0, 420.0, 380.0, 480.0));
}
#[test]
fn a_pen_anchor_lines_up_with_another_objects_centre() {
let mut s = session();
s.select_tool("pen", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 300.0, 420.0);
pointer(&mut s, PointerKind::Up, 300.0, 420.0);
// Hovering: in line with the square's centre (150, 150), the guide running from it.
pointer(&mut s, PointerKind::Move, 151.5, 431.0);
let ov = s.overlays(ViewInfo::default());
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if *a == Point::new(150.0, 150.0) && *b == Point::new(150.0, 431.0))), "{ov:?}");
assert_eq!(labels(&mut s), [("align".to_string(), Point::new(150.0, 431.0))]);
pointer(&mut s, PointerKind::Down, 151.5, 431.0);
pointer(&mut s, PointerKind::Up, 151.5, 431.0);
// Shift: along the 45° step from it, slid into line with the square's left side (x = 100).
s.pointer(&PointerEvent::new(PointerKind::Down, 103.0, 386.0).with_mods(Mods { shift: true, ..Mods::default() }), ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Up, 103.0, 386.0);
let d = s.doc().unwrap();
let path = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap();
let pts: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts[..2], [Point::new(300.0, 420.0), Point::new(150.0, 431.0)]);
assert!((pts[2] - Point::new(100.0, 381.0)).hypot() < 1e-9, "{pts:?}");
}
/// Preferences › Smart Guides › Construction Guides: on (the default), a Pen anchor near the
/// 45° guide through the last one lands on it; off, it stays where it was clicked.
#[test]
fn construction_guides_follow_the_preference() {
for on in [true, false] {
let mut s = session();
s.execute("prefs.set", &json!({"key": "constructionGuides", "value": on})).unwrap();
s.select_tool("pen", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 500.0, 500.0);
pointer(&mut s, PointerKind::Up, 500.0, 500.0);
pointer(&mut s, PointerKind::Down, 561.0, 438.0);
pointer(&mut s, PointerKind::Up, 561.0, 438.0);
let d = s.doc().unwrap();
let last = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[1].p;
let want = if on { Point::new(561.5, 438.5) } else { Point::new(561.0, 438.0) };
assert!((last - want).hypot() < 1e-9, "{on}: {last:?}");
}
}
+6
View File
@@ -178,6 +178,7 @@ impl Session {
let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit());
let cx = ToolContext {
doc: &st.doc,
revision: (st.uid, st.revision),
selection: &st.selection,
zoom: view.zoom,
isolation: st.isolation,
@@ -222,6 +223,11 @@ impl Session {
measurement_labels: self.prefs.measurement_labels,
transform_tools_guides: self.prefs.transform_tools_guides,
snapping_tolerance: self.prefs.snapping_tolerance,
construction_angles: if self.prefs.construction_guides {
vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles)
} else {
&[]
},
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
+27
View File
@@ -588,3 +588,30 @@ fn type_preferences_apply_to_agents() {
assert_eq!(r["isError"], false, "{r}");
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
}
/// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn
/// from near another object's corner starts on it, and a Pen anchor placed beside that object's
/// centre lines up with it.
#[test]
fn drawing_gestures_snap_to_smart_guides() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 800, "height": 600}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100}));
assert_eq!(r["isError"], false, "{r}");
let bounds = |s: &mut Server, n: u64, events: Value, tool: &str| {
let r = call(s, n, "pointer_gesture", json!({"tool": tool, "events": events}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].clone();
let doc: Value = serde_json::from_str(&text_of(&call(s, n + 1, "inspect_document", json!({})))).unwrap();
let made = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("drawn").clone();
let b = &made["bounds"];
[&b["x"], &b["y"], &b["width"], &b["height"]].map(|v| v.as_f64().unwrap())
};
let events = json!([{"kind": "move", "x": 202, "y": 203}, {"kind": "down", "x": 202, "y": 203}, {"kind": "drag", "x": 330, "y": 341}, {"kind": "up", "x": 330, "y": 341}]);
assert_eq!(bounds(&mut s, 3, events, "rectangle"), [200.0, 200.0, 130.0, 141.0]);
let r = call(&mut s, 5, "run_command", json!({"command": "select.none", "params": {}}));
assert_eq!(r["isError"], false, "{r}");
let events = json!([{"kind": "down", "x": 300, "y": 420}, {"kind": "up", "x": 300, "y": 420}, {"kind": "move", "x": 151.5, "y": 431}, {"kind": "down", "x": 151.5, "y": 431}, {"kind": "up", "x": 151.5, "y": 431}]);
assert_eq!(bounds(&mut s, 6, events, "pen"), [150.0, 420.0, 150.0, 11.0]);
}
+45 -10
View File
@@ -3,13 +3,15 @@
//! Each click adds a point and the path curves smoothly through all points. Alt-click or
//! double-click a point toggles it between smooth and corner; drag a point to move it; click the
//! 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).
//! 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.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_doc::{NodeId, Selection};
use vectorcraft_geom::Point;
use super::{FEEDBACK, catmull_rom};
use crate::guides::{DrawSnap, Leave, Targets};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
@@ -22,6 +24,7 @@ pub struct CurvatureTool {
/// Last touched point (Backspace removes it).
current: Option<usize>,
hover: Option<Point>,
snap: DrawSnap,
}
impl CurvatureTool {
@@ -55,6 +58,12 @@ impl CurvatureTool {
self.id = None;
self.drag = None;
self.current = 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))
}
}
@@ -73,11 +82,12 @@ impl Tool for CurvatureTool {
}
match ev.kind {
PointerKind::Move => {
self.hover = Some(p);
let from = active.and_then(|_| self.leave(cx));
self.hover = Some(self.snap.hover(cx, p, active.as_slice(), from.as_ref()));
vec![]
}
PointerKind::Down => {
if active.is_some() {
if let Some(id) = active {
if let Some(i) = self.hit_point(cx, p) {
self.current = Some(i);
if ev.mods.alt {
@@ -88,10 +98,15 @@ impl Tool for CurvatureTool {
self.closed = true;
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));
return vec![];
}
if !self.closed {
let from = self.leave(cx);
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())];
@@ -99,6 +114,7 @@ impl Tool for CurvatureTool {
}
// Start a new path.
self.reset();
let p = self.snap.press(cx, p, &[], None);
self.pts.push((p, false));
self.current = Some(0);
vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())]
@@ -113,16 +129,20 @@ impl Tool for CurvatureTool {
out.push(Action::Begin("Curvature".into()));
self.drag = Some((i, 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
}
PointerKind::Up => match self.drag.take() {
Some((_, _, true)) => vec![Action::Commit],
_ => vec![],
},
PointerKind::Up => {
self.snap.clear();
match self.drag.take() {
Some((_, _, true)) => vec![Action::Commit],
_ => vec![],
}
}
PointerKind::DoubleClick => {
if active.is_none() {
return vec![];
@@ -163,10 +183,10 @@ impl Tool for CurvatureTool {
if dragging { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if self.active(cx).is_none() {
return vec![];
return o;
}
let mut o = vec![];
if cx.curvature_rubber_band
&& !self.closed
&& self.drag.is_none()
@@ -217,4 +237,19 @@ mod tests {
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 400.0));
assert!(matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"].as_array().unwrap().len() == 5));
}
/// Points snap to Smart Guides (#506): the first lands on another object's anchor and says so.
#[test]
fn points_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = CurvatureTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0));
assert!(matches!(&a[0], Action::Exec(_, v) if v["points"][0]["x"] == 200.0 && v["points"][0]["y"] == 100.0), "{a:?}");
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor")));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.0, 99.0));
assert!(t.overlays(&cx).is_empty());
}
}
+49 -23
View File
@@ -1,13 +1,16 @@
//! Line Segment family drag tools: Arc, Spiral, Rectangular Grid and Polar Grid.
//!
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog.
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog. The
//! start point and the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering too.
//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→
//! change grid columns / radial dividers.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_geom::Point;
use crate::guides::{DrawSnap, Leave, square};
use crate::shape::drag_rect;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct FamilyTool {
@@ -24,6 +27,7 @@ pub struct FamilyTool {
pub rows: u32,
/// Columns / radial dividers.
pub columns: u32,
snap: DrawSnap,
}
impl FamilyTool {
@@ -46,6 +50,7 @@ impl FamilyTool {
clockwise: true,
rows: 5,
columns: 5,
snap: DrawSnap::default(),
}
}
@@ -58,13 +63,10 @@ impl FamilyTool {
}
}
fn rect(start: Point, p: Point, m: Mods) -> Rect {
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
}
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
/// How the dragged point keeps to the start: a spiral's radius goes anywhere, the arc's and
/// the grids' corner keeps to a diagonal with Shift.
fn leave(&self, start: Point, m: Mods) -> Option<Leave> {
(self.id != "spiral").then(|| Leave::diagonal(start, m.shift))
}
/// The command for a drag from `start` to `p`.
@@ -78,25 +80,21 @@ impl FamilyTool {
)
}
"rectangularGrid" => {
let r = Self::rect(start, p, m);
let r = drag_rect(start, p, m);
(
"shape.rectangularGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}),
)
}
"polarGrid" => {
let r = Self::rect(start, p, m);
let r = drag_rect(start, p, m);
(
"shape.polarGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}),
)
}
_ => {
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
}
let d = if m.shift { square(p - start) } else { p - start };
let (a, b) = if m.alt { (start - d, start + d) } else { (start, start + d) };
("shape.arc".into(), json!({"x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y, "closed": self.closed}))
}
@@ -113,8 +111,12 @@ impl Tool for FamilyTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.snap.hover(cx, ev.pos, &[], None);
vec![]
}
PointerKind::Down => {
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
let p = self.snap.press(cx, ev.pos, &[], None);
self.start = Some(p);
self.last = p;
self.began = false;
@@ -122,6 +124,8 @@ impl Tool for FamilyTool {
}
PointerKind::Drag => {
let Some(mut s) = self.start else { return vec![] };
let pos = self.snap.drag(cx, ev.pos, self.leave(s, ev.mods).as_ref());
let ev = &PointerEvent { pos, ..*ev };
crate::shape::space_moves(&mut s, &mut self.last, ev, self.began);
self.start = Some(s);
self.mods = ev.mods;
@@ -139,6 +143,7 @@ impl Tool for FamilyTool {
}
PointerKind::Up => {
let Some(s) = self.start.take() else { return vec![] };
self.snap.clear();
if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] }
}
_ => vec![],
@@ -179,19 +184,21 @@ impl Tool for FamilyTool {
} else if key == ToolKey::Escape {
self.start = None;
self.began = false;
self.snap.clear();
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match self.start {
Some(s) if self.began && cx.measurement_labels => {
let d = self.last - s;
vec![Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) }]
}
_ => vec![],
let mut o = self.snap.guides().to_vec();
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
// The size as drawn (from the centre with Alt, square with Shift).
let r = drag_rect(s, self.last, self.mods);
let (w, h) = if self.id == "spiral" { ((self.last - s).x.abs(), (self.last - s).y.abs()) } else { (r.width(), r.height()) };
o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) });
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
@@ -267,4 +274,23 @@ mod tests {
assert_eq!(c, "shape.arc");
assert_eq!(v, json!({"x1": 0.0, "y1": 0.0, "x2": 10.0, "y2": 10.0, "closed": false}));
}
/// The start and the dragged corner snap to Smart Guides (#506), Shift sliding the corner of
/// a square grid along its diagonal into line.
#[test]
fn corners_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = FamilyTool::new("rectangularGrid");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0));
let shift = Mods { shift: true, ..Mods::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 255.0, 147.0).with_mods(shift));
assert!(
matches!(&a[..], [Action::Begin(_), Action::Preview(_, v)] if v["x"] == 200.0 && v["y"] == 100.0 && v["width"] == 50.0 && v["height"] == 50.0),
"{a:?}"
);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")));
}
}
+58 -6
View File
@@ -3,14 +3,17 @@
//!
//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot)
//! 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 other tools collect the drag polyline
//! (shown as an overlay) and run one command on release.
//! 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
//! one command on release.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{FEEDBACK, points_json, polyline};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct GestureTool {
@@ -30,6 +33,8 @@ pub struct GestureTool {
pub edit_within: f64,
/// "Close paths when ends are within N pixels".
pub close_within: f64,
/// Smart Guides for the Pencil's ends.
snap: DrawSnap,
}
impl GestureTool {
@@ -56,9 +61,15 @@ impl GestureTool {
fill: false,
edit_within: 12.0,
close_within: 15.0,
snap: DrawSnap::default(),
}
}
/// Do the ends of its strokes snap (the Pencil)?
fn snaps(&self) -> bool {
self.id == "pencil"
}
fn draws_path(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush")
}
@@ -147,17 +158,27 @@ impl Tool for GestureTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move if self.snaps() => {
self.snap.hover(cx, p, &[], None);
vec![]
}
PointerKind::Down => {
self.points = vec![p];
self.active = true;
self.began = false;
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];
vec![]
}
PointerKind::Drag => {
if !self.active {
return vec![];
}
if self.snaps() {
// Where the stroke would end: shown, taken on release.
self.snap.drag(cx, p, None);
}
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
@@ -177,8 +198,16 @@ impl Tool for GestureTool {
if !self.active {
return vec![];
}
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(p);
self.points.push(end);
} else if self.snaps()
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
{
// The last step was where the button went up: it moves where that snaps.
*last = end;
}
let out = self.finish(cx, ev.mods);
self.active = false;
@@ -194,6 +223,7 @@ impl Tool for GestureTool {
match key {
ToolKey::Escape if self.active => {
let began = self.began;
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
@@ -212,21 +242,24 @@ impl Tool for GestureTool {
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let began = self.began;
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
if began { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if !self.active || self.points.len() < 2 || self.began {
return vec![];
return o;
}
let width = match self.id {
"blobBrush" | "eraser" => (self.size * cx.zoom) as f32,
_ => 1.0,
};
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
vec![Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }]
o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" });
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
@@ -269,6 +302,25 @@ mod tests {
v
}
/// The Pencil's new path starts and ends on Smart Guides (#506), the points between where
/// the hand went.
#[test]
fn pencil_ends_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("pencil");
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default());
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]]));
// The Paintbrush follows the hand all the way.
let mut t = GestureTool::new("paintbrush");
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default());
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["points"][0], json!([102.0, 98.0]));
}
#[test]
fn pencil_previews_and_commits() {
let (d, _) = doc_with_rect();
+539 -51
View File
@@ -1,15 +1,23 @@
//! Smart Guides: snapping to anchors, path segments (for dragged anchors), object bounds
//! (edges/centres), artboards, with the magenta construction lines and labels Illustrator users
//! expect. Preferences › Smart Guides sets their colour, which of them show ([`GuideStyle`]) and
//! how near a target pulls (`ToolContext::snapping_tolerance`).
//! Smart Guides: snapping to anchors, path segments (for dragged anchors and drawn points), object
//! bounds (edges/centres), artboards, with the magenta construction lines and labels Illustrator
//! users expect. Preferences › Smart Guides sets their colour, which of them show ([`GuideStyle`]),
//! how near a target pulls (`ToolContext::snapping_tolerance`) and the angles of the construction
//! guides drawn points land on (`ToolContext::construction_angles`).
//!
//! The drawing tools (Pen, Curvature, Pencil, the shape tools, Type, Artboard) all snap the points
//! they place through one [`DrawSnap`]: their targets are gathered once per document state, sorted,
//! and each pointer move looks up the few near the pointer by bisection.
use std::collections::HashSet;
use std::sync::Arc;
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, Selection};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, OrientedBox, Selection};
use vectorcraft_geom::kurbo::ParamCurveNearest;
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2};
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2, constrain_angle_from};
use crate::bbox::Handle;
use crate::{Overlay, ToolContext};
use crate::{Overlay, ScreenFrame, ToolContext};
pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc];
@@ -102,9 +110,93 @@ impl Ruler {
/// How many anchors, and how many segments, the targets gather at most.
const BUDGET: usize = 20_000;
/// What [`Targets::collect_inner`] gathers from each path besides its anchors and bounds.
#[derive(Clone, Copy, PartialEq)]
enum Gather {
/// Nothing more: a moved or resized selection lines up with bounds.
Bounds,
/// Its segments, which a dragged anchor lands on.
Segments,
/// Its segments, and its anchors as lines too: a drawn point lines up with them.
Drawing,
}
/// The entries of `sorted` (ascending by `key`) whose key is within `tol` of `v`, found by
/// bisection.
fn within<T>(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] {
let lo = sorted.partition_point(|e| key(e) < v - tol);
let hi = sorted.partition_point(|e| key(e) <= v + tol);
sorted.get(lo..hi).unwrap_or_default()
}
/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those:
/// one pass, however many are skipped.
fn walk_but<'a>(nodes: &'a [Arc<Node>], skip: &HashSet<NodeId>, f: &mut impl FnMut(&'a Node)) {
for n in nodes.iter().filter(|n| !skip.contains(&n.id)) {
f(n);
if let Some(ch) = n.children() {
walk_but(ch, skip, f);
}
}
}
/// The lines of `lines` (sorted by where they run) within `tol` of `v`.
fn lines_near(lines: &[(f64, Point, Kind)], v: f64, tol: f64) -> &[(f64, Point, Kind)] {
within(lines, |l| l.0, v, tol)
}
impl Targets {
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
Self::collect_inner(doc, exclude, None, visible, false)
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
}
/// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its
/// anchors, centres, edges and path segments, its anchors in line too. The anchors and
/// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets.
pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
let mut t = Self::collect_inner(doc, exclude, None, visible, Gather::Drawing);
t.push_paths(doc, exclude, true);
t.index()
}
/// Targets for a dragged direction handle of path `id`: the other art, and the anchors of `id`
/// (its bounds and the segments through the handle move with it, so they would chase it).
pub fn for_handle(doc: &Document, id: NodeId) -> Self {
let mut t = Self::collect_inner(doc, &[id], None, None, Gather::Bounds);
t.push_paths(doc, &[id], false);
t.index()
}
/// The anchors of the paths `ids` as targets, on them and in line, and their segments too
/// when `segments`: at most [`BUDGET`] of each.
fn push_paths(&mut self, doc: &Document, ids: &[NodeId], segments: bool) {
let (mut anchors, mut segment_budget) = (BUDGET, if segments { BUDGET } else { 0 });
for path in ids.iter().filter_map(|id| doc.node(*id)?.path_data()) {
for sp in &path.subpaths {
for a in sp.anchors.iter().take(anchors) {
self.push_anchor(a.p);
anchors -= 1;
}
self.push_segments(sp, |_| false, &mut segment_budget);
}
}
}
/// Sort the targets so that [`within`] finds those near a point by bisection: the anchors
/// and centres by x, the lines by where they run. Every constructor ends with it (the sort
/// is stable: of targets alike, the first gathered still wins).
fn index(mut self) -> Self {
self.points.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
self.xs.sort_by(|a, b| a.0.total_cmp(&b.0));
self.ys.sort_by(|a, b| a.0.total_cmp(&b.0));
self
}
/// Anchor `a` as a target: a point lands on it and lines up with it.
fn push_anchor(&mut self, a: Point) {
self.points.push((a, Kind::Anchor));
self.xs.push((a.x, a, Kind::Anchor));
self.ys.push((a.y, a, Kind::Anchor));
}
/// The same targets drawing their guides as the Smart Guides preferences in `cx` ask
@@ -127,29 +219,27 @@ impl Targets {
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
/// art that moves along with it).
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
Self::collect_inner(doc, exclude, Some(index), None, false)
Self::collect_inner(doc, exclude, Some(index), None, Gather::Bounds)
}
/// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a
/// whole): the other art, its path segments too, and the anchors and segments that stay put on
/// the paths being reshaped (their bounds move along, so they are no targets).
pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self {
let mut t = Self::collect_inner(doc, &sel.objects, None, None, true);
let mut t = Self::collect_inner(doc, &sel.objects, None, None, Gather::Segments);
let (mut anchors, mut segments) = (BUDGET, BUDGET);
for (id, set) in &sel.anchors {
let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, sp) in path.subpaths.iter().enumerate() {
let moving = |ai: usize| set.contains(&(si, ai));
for a in sp.anchors.iter().enumerate().filter(|(ai, _)| !moving(*ai)).map(|(_, a)| a.p).take(anchors) {
t.points.push((a, Kind::Anchor));
t.xs.push((a.x, a, Kind::Anchor));
t.ys.push((a.y, a, Kind::Anchor));
t.push_anchor(a);
anchors -= 1;
}
t.push_segments(sp, moving, &mut segments);
}
}
t
t.index()
}
/// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at
@@ -171,9 +261,8 @@ impl Targets {
}
}
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, segments: bool) -> Self {
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, gather: Gather) -> Self {
let mut t = Targets::default();
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
let c = r.center();
for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] {
@@ -194,8 +283,8 @@ impl Targets {
}
}
let (mut budget, mut segment_budget) = (BUDGET, BUDGET);
doc.walk(|n| {
if budget == 0 || n.is_container() || !n.visible || excluded(n.id) {
walk_but(&doc.layers, &exclude.iter().copied().collect(), &mut |n| {
if budget == 0 || n.is_container() || !n.visible {
return;
}
let Some(b) = n.geometric_bounds() else { return };
@@ -207,10 +296,14 @@ impl Targets {
}
if let NodeKind::Path { path, .. } = &n.kind {
for (_, _, a) in path.anchors() {
t.points.push((a.p, Kind::Anchor));
if gather == Gather::Drawing {
t.push_anchor(a.p);
} else {
t.points.push((a.p, Kind::Anchor));
}
budget = budget.saturating_sub(1);
}
if segments {
if gather != Gather::Bounds {
for sp in &path.subpaths {
t.push_segments(sp, |_| false, &mut segment_budget);
}
@@ -218,7 +311,7 @@ impl Targets {
}
add_rect(&mut t, b, Kind::Edge);
});
t
t.index()
}
/// Only these targets' anchors pull (View → Snap to Point).
@@ -265,25 +358,55 @@ impl Targets {
t.points.push((b.center(), Kind::Center));
});
}
t
t.index()
}
/// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else
/// into line with targets. Returns the snapped point and guide overlays.
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
self.snap_from(p, None, tol)
}
/// Snap a drawn point `p` as [`Self::snap_point`] does, and when it leaves `from` (the anchor
/// before it, a shape's start): with Shift held it keeps to its angles round it, sliding along
/// that way into line with the nearest target; else, before lining up, it lands on a
/// construction guide through it, where a target's line crosses the guide if one does nearby.
pub fn snap_from(&self, p: Point, from: Option<&Leave>, tol: f64) -> (Point, Vec<Overlay>) {
if let Some(f) = from
&& let Some(q) = f.constrained(p)
{
return match self.slide(f.at, q - f.at, q, tol, true) {
Some((q, target)) => (q, self.lined_up(q, target)),
None => (q, vec![]),
};
}
if let Some((q, k)) = self.nearest_point(p, tol) {
return (q, self.label(q, k.label()).into_iter().collect());
}
if let Some(q) = self.on_segment(p, tol) {
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
}
if let Some(f) = from
&& let Some((dir, q)) = f.construction(p, tol)
{
let construction = |q: Point| Overlay::Line { a: f.at, b: q, color: self.style.color, dashed: false };
return match self.slide(f.at, dir, q, tol, false) {
Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()),
None => (q, vec![construction(q)]),
};
}
let mut out = p;
let mut ov = vec![];
// The targets' lines along one axis and the ruler guides running past `p`, the nearest
// within reach of `v`.
let nearest = |lines: &[(f64, Point, Kind)], vertical: bool, v: f64| {
let rulers = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(p, tol));
lines.iter().copied().chain(rulers).filter(|(t, ..)| (t - v).abs() <= tol).min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()))
lines_near(lines, v, tol)
.iter()
.copied()
.chain(rulers)
.filter(|(t, ..)| (t - v).abs() <= tol)
.min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()))
};
let mut aligned = false;
if let Some((x, from, _)) = nearest(&self.xs, true, p.x) {
@@ -304,7 +427,47 @@ impl Targets {
/// The anchor or centre nearest `p`, within `tol`.
fn nearest_point(&self, p: Point, tol: f64) -> Option<(Point, Kind)> {
self.points.iter().copied().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p)))
within(&self.points, |q| q.0.x, p.x, tol)
.iter()
.copied()
.filter(|(q, _)| q.distance(p) <= tol)
.min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p)))
}
/// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where
/// the nearest target line crosses it, at most twice `tol` away: where it lands and the
/// target's own point (where its line starts).
fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> {
if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 {
return None;
}
let reach = 2.0 * tol;
let mut best: Option<(f64, Point, Point)> = None;
for (vertical, lines, d, a, v) in [(true, &self.xs, dir.x, at.x, q.x), (false, &self.ys, dir.y, at.y, q.y)] {
if d.abs() <= 1e-9 {
continue;
}
// A line that far across the way crosses it at least that far along.
for (t, from, _) in lines_near(lines, v, reach) {
let s = (t - a) / d;
// Exactly on the line, whatever the rounding along the way.
let hit = if vertical { Point::new(*t, at.y + dir.y * s) } else { Point::new(at.x + dir.x * s, *t) };
let moved = hit.distance(q);
if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) {
best = Some((moved, hit, *from));
}
}
}
best.map(|(_, hit, from)| (hit, from))
}
/// The guides of a point `q` slid into line with the target at `from`: the line from it and
/// "align", or the target's own label when `q` sits right on it.
fn lined_up(&self, q: Point, from: Point) -> Vec<Overlay> {
if let Some((on, k)) = self.nearest_point(q, 1e-9) {
return self.label(on, k.label()).into_iter().collect();
}
self.line(from, q).into_iter().chain(self.label(q, "align")).collect()
}
/// The nearest point within `tol` of `p` on a target segment.
@@ -323,7 +486,7 @@ impl Targets {
pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec<Overlay>) {
let along = |q: &Point| if vertical { q.x } else { q.y };
let lines = if vertical { &self.xs } else { &self.ys };
let best = lines
let best = lines_near(lines, v, tol)
.iter()
.map(|(t, from, k)| (*t, *from, *k))
.chain(self.points.iter().map(|(q, k)| (along(q), *q, *k)))
@@ -370,7 +533,7 @@ impl Targets {
rects
.iter()
.flat_map(|r| along(r).into_iter().map(move |v| (v, *r)))
.flat_map(|(v, r)| targets.iter().map(move |(t, from, _)| (t - v, *from, v, r)))
.flat_map(|(v, r)| lines_near(targets, v, tol).iter().map(move |(t, from, _)| (t - v, *from, v, r)))
.filter(|(d, ..)| d.abs() <= tol)
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()))
};
@@ -410,7 +573,11 @@ impl Targets {
// A scale that collapses or flips the box is no snap.
let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0;
let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit {
ts.iter().map(|(t, from, _)| (*t, *from)).filter(|(t, _)| (t - v).abs() <= tol).min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
lines_near(ts, v, tol)
.iter()
.map(|(t, from, _)| (*t, *from))
.filter(|(t, _)| (t - v).abs() <= tol)
.min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
};
let (mut nx, mut ny) = (sx, sy);
let mut snap: Snap = (None, None, None);
@@ -540,24 +707,225 @@ impl PointSnap {
/// Where `p` goes, and the guides showing why.
pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
self.snap_from(cx, p, None)
}
/// Where a drawn point `p` leaving `from` goes ([`Targets::snap_from`]), and the guides
/// showing why. Pixels and the grid round it before Shift turns it onto its angle.
pub fn snap_from(&self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> (Point, Vec<Overlay>) {
let keep = |q: Point| from.and_then(|f| f.constrained(q)).unwrap_or(q);
if cx.snap_to_pixel {
return (Point::new(p.x.round(), p.y.round()), vec![]);
return (keep(Point::new(p.x.round(), p.y.round())), vec![]);
}
if cx.snap_to_grid {
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
return (keep(vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step())), vec![]);
}
match &self.targets {
Some((t, tol)) => t.snap_point(p, cx.tol(*tol)),
None => (p, vec![]),
Some((t, tol)) => t.snap_from(p, from, cx.tol(*tol)),
None => (keep(p), vec![]),
}
}
}
/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it
/// snaps as a drawn point does. The targets are gathered on the first move that needs them and
/// kept for the rest of the drag (gathering them takes as long as the document is big).
/// The Construction Guides angle set new preferences start with.
pub const DEFAULT_CONSTRUCTION_ANGLES: &str = "90° & 45° Angles";
/// The angles (degrees) of the construction guides of a Smart Guides › Angles set: "90°" gives the
/// horizontal and the vertical, "45°" the diagonals, "60°" and "30°" their multiples between those.
pub fn construction_angles(set: &str) -> &'static [f64] {
match set {
"30° Angles" => &[30.0, 60.0, 120.0, 150.0],
"45° Angles" => &[45.0, 135.0],
"60° Angles" => &[60.0, 120.0],
"90° Angles" => &[0.0, 90.0],
"90° & 45° & 30° Angles" => &[0.0, 30.0, 45.0, 60.0, 90.0, 120.0, 135.0, 150.0],
_ => &[0.0, 45.0, 90.0, 135.0],
}
}
/// How Shift keeps a drawn point to the point it leaves from.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Keep {
/// At steps of `step` degrees counted from `base` (a segment).
Angle { step: f64, base: f64 },
/// On a diagonal, as far along as the pointer's farther axis (a square's corner).
Square,
}
/// The point a drawn point leaves from (the anchor before it, a shape's start) and how it keeps
/// to it: with Shift held at fixed angles round it, and onto the construction guides through it
/// (Smart Guides › Construction Guides).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Leave {
pub at: Point,
/// Shift held: how the point keeps to `at`.
shift: Option<Keep>,
/// The angles of the construction guides through `at` (none with them off).
angles: &'static [f64],
}
impl Leave {
/// A segment from `at` (Pen, Curvature, Line Segment): Shift keeps it at 45° steps from
/// General › Constrain Angle; with Smart Guides on it lands on the construction guides.
pub fn segment(cx: &ToolContext, at: Point, shift: bool) -> Self {
Self {
at,
shift: shift.then_some(Keep::Angle { step: 45.0, base: cx.constrain_angle }),
angles: if cx.smart_guides { cx.construction_angles } else { &[] },
}
}
/// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids,
/// Artboard): Shift keeps the dragged corner on a diagonal, the box a square.
pub fn diagonal(at: Point, shift: bool) -> Self {
Self { at, shift: shift.then_some(Keep::Square), angles: &[] }
}
/// Where Shift puts `p`: none without it.
fn constrained(&self, p: Point) -> Option<Point> {
let v = p - self.at;
Some(match self.shift? {
Keep::Angle { step, base } => self.at + constrain_angle_from(v, step, base),
Keep::Square => self.at + square(v),
})
}
/// The construction guide through `at` nearest `p`, within `tol` of it (and `p` farther than
/// that from `at`, where every guide meets): its direction and where `p` lands on it.
fn construction(&self, p: Point, tol: f64) -> Option<(Vec2, Point)> {
let v = p - self.at;
if v.hypot() <= tol {
return None;
}
self.angles
.iter()
// Counter-clockwise as seen on the y-down page.
.map(|a| Vec2::new(a.to_radians().cos(), -a.to_radians().sin()))
.map(|d| (d, self.at + d * v.dot(d)))
.map(|(d, q)| (d, q, q.distance(p)))
.filter(|(.., dist)| *dist <= tol)
.min_by(|a, b| a.2.total_cmp(&b.2))
.map(|(d, q, _)| (d, q))
}
}
/// `d` made square: as long on both axes as on its longer one, each keeping its sign.
pub fn square(d: Vec2) -> Vec2 {
let s = d.x.abs().max(d.y.abs());
Vec2::new(s * d.x.signum(), s * d.y.signum())
}
/// Smart Guides for the points a drawing tool places: the Pen's and Curvature tool's anchors,
/// the Pencil's ends, the shape tools' start and dragged corner, the Type tool's click and the
/// Artboard tool's new artboard. Each such tool keeps one and asks it for every point, hovering
/// too, so they all snap alike, and shows its [`Self::guides`].
///
/// The targets ([`Targets::for_drawing`], the art in the window) are gathered once per document
/// state: hovering gathers them again only after the document, the selection, what is left out,
/// the window or the snapping options changed; a press always gathers them afresh, and the drag
/// it starts keeps them, the document then showing the drag's own previews.
#[derive(Default)]
pub struct HandleSnap(Option<PointSnap>);
pub struct DrawSnap {
/// The targets, and what they were gathered for (none: for one drag only).
targets: Option<(Option<SnapKey>, PointSnap)>,
guides: Vec<Overlay>,
}
/// What a [`DrawSnap`]'s targets were gathered for: the document state, the art left out, the
/// window and every option [`PointSnap::new`] reads.
#[derive(PartialEq)]
struct SnapKey {
revision: (u64, u64),
exclude: Vec<NodeId>,
screen: Option<ScreenFrame>,
view: [bool; 5],
style: GuideStyle,
tolerances: [f64; 2],
}
impl SnapKey {
fn of(cx: &ToolContext, exclude: &[NodeId]) -> Self {
Self {
revision: cx.revision,
exclude: exclude.to_vec(),
screen: cx.screen,
view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides],
style: GuideStyle::of(cx),
tolerances: [cx.snapping_tolerance, cx.snap_tolerance],
}
}
}
/// The part of the document the window shows (none headless: all of it).
fn visible(cx: &ToolContext) -> Option<Rect> {
let s = cx.screen?;
let (w, h) = s.size;
Some(Rect::from_points(s.at(0.0, 0.0), s.at(w, h)).union_pt(s.at(w, 0.0)).union_pt(s.at(0.0, h)))
}
impl DrawSnap {
/// Snap the pointer hovering at `p` (the art but `exclude` as targets, the anchors of
/// `exclude` still pulling), reusing the targets while nothing they depend on changed.
pub fn hover(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point {
let key = SnapKey::of(cx, exclude);
if self.targets.as_ref().is_none_or(|(k, _)| k.as_ref() != Some(&key)) {
self.gather(cx, key);
}
self.snap(cx, p, from)
}
/// Snap the point pressed at `p`, the targets gathered afresh: the drag that follows keeps
/// them ([`Self::drag`]).
pub fn press(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point {
self.gather(cx, SnapKey::of(cx, exclude));
self.snap(cx, p, from)
}
/// Gather `targets` other than the art for the drag a press starts (a point being dragged
/// leaves itself out); the next hover gathers its own again.
pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) {
self.targets = Some((None, PointSnap::new(cx, targets)));
self.guides.clear();
}
/// Snap `p`, dragged since the press, with the targets the press gathered.
pub fn drag(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point {
if self.targets.is_none() {
self.gather(cx, SnapKey::of(cx, &[]));
}
self.snap(cx, p, from)
}
/// The guides of the last snap.
pub fn guides(&self) -> &[Overlay] {
&self.guides
}
/// Hide the guides (the gesture ended).
pub fn clear(&mut self) {
self.guides.clear();
}
fn gather(&mut self, cx: &ToolContext, key: SnapKey) {
let snap = PointSnap::new(cx, || Targets::for_drawing(cx.doc, &key.exclude, visible(cx)));
self.targets = Some((Some(key), snap));
}
fn snap(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point {
let (q, guides) = match &self.targets {
Some((_, t)) => t.snap_from(cx, p, from),
None => (p, vec![]),
};
self.guides = guides;
q
}
}
/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it
/// snaps as a drawn point does ([`DrawSnap`]). The targets ([`Targets::for_handle`]) are gathered
/// on the first move that needs them and kept for the rest of the drag.
#[derive(Default)]
pub struct HandleSnap(DrawSnap);
impl HandleSnap {
/// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`,
@@ -565,22 +933,13 @@ impl HandleSnap {
pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
let path = cx.doc.node(id).and_then(|n| n.path_data());
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
return (a.p + constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
}
let snap = self.0.get_or_insert_with(|| {
PointSnap::new(cx, || {
// The path's bounds move with the handle, so they would chase it: its anchors
// (which stay put) are the targets instead, the handle lining up with them too.
let mut t = Targets::collect(cx.doc, &[id], None);
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
t.points.push((a.p, Kind::Anchor));
t.xs.push((a.p.x, a.p, Kind::Anchor));
t.ys.push((a.p.y, a.p, Kind::Anchor));
}
t
})
});
snap.snap(cx, p)
if self.0.targets.is_none() {
self.0.hold(cx, || Targets::for_handle(cx.doc, id));
}
let q = self.0.drag(cx, p, None);
(q, self.0.guides().to_vec())
}
}
@@ -758,9 +1117,138 @@ mod tests {
s.anchors.insert(id, [(0, 0)].into_iter().collect());
let t = Targets::for_anchor_drag(&d, &s);
let anchors: Vec<Point> = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect();
assert_eq!(anchors, [Point::new(200.0, 100.0), Point::new(200.0, 200.0), Point::new(100.0, 200.0)]);
assert_eq!(anchors, [Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], "by x");
assert_eq!(t.segments.len(), 2);
assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves");
assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself");
}
fn labels(ov: &[Overlay]) -> Vec<&str> {
ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect()
}
fn lines(ov: &[Overlay]) -> Vec<(Point, Point)> {
ov.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((*a, *b)) } else { None }).collect()
}
/// A drawn point (#506): on another object's anchors, in line with them too (not only with its
/// bounds), onto its paths; Shift slides it along its 45° step into line; near a construction
/// guide through the point it leaves from it lands on the guide, where a target's line crosses
/// it if one does nearby.
#[test]
fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let t = Targets::for_drawing(&d, &[], None).styled(&c);
// The rect is 100..200: its corner, a point on its side, and in line with an anchor.
let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"]));
let (q, ov) = t.snap_from(Point::new(202.0, 160.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(200.0, 160.0), vec!["path"]));
let (q, ov) = t.snap_from(Point::new(330.0, 102.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(330.0, 100.0), vec!["align"]));
// Shift from (300, 300): the pointer at 2° keeps to 0°, then slides onto x = 200.
let shift = Leave::segment(&c, Point::new(300.0, 300.0), true);
let (q, ov) = t.snap_from(Point::new(203.0, 303.0), Some(&shift), 4.0);
assert!((q - Point::new(200.0, 300.0)).hypot() < 1e-9, "{q:?}");
assert_eq!(labels(&ov), ["align"]);
assert_eq!(lines(&ov).len(), 1, "{ov:?}");
// Nothing in reach: on its angle, as far as the pointer.
let (q, ov) = t.snap_from(Point::new(300.0, 380.0), Some(&shift), 4.0);
assert_eq!((q, ov.len()), (Point::new(300.0, 380.0), 0));
// Construction guides through (300, 320): onto the 135° one…
let from = Leave::segment(&c, Point::new(300.0, 320.0), false);
let (q, ov) = t.snap_from(Point::new(352.0, 373.0), Some(&from), 4.0);
assert!((q - Point::new(352.5, 372.5)).hypot() < 1e-9, "{q:?}");
assert_eq!(lines(&ov), vec![(Point::new(300.0, 320.0), q)]);
// …where x = 200 crosses it.
let (q, ov) = t.snap_from(Point::new(203.0, 222.5), Some(&from), 4.0);
assert!((q - Point::new(200.0, 220.0)).hypot() < 1e-9, "{q:?}");
assert_eq!((lines(&ov).len(), labels(&ov)), (2, vec!["align"]));
// Construction Guides off (or Smart Guides off): in line, not on a guide.
let off = ToolContext { construction_angles: &[], ..cx(&d, &s, &p) };
let (q, _) = t.snap_from(Point::new(352.0, 373.0), Some(&Leave::segment(&off, Point::new(300.0, 320.0), false)), 4.0);
assert_eq!(q, Point::new(352.0, 373.0));
// A box's corner keeps to a diagonal with Shift, the farther axis giving its size.
let (q, _) = t.snap_from(Point::new(340.0, 310.0), Some(&Leave::diagonal(Point::new(300.0, 300.0), true)), 4.0);
assert_eq!(q, Point::new(340.0, 340.0));
}
/// The angle sets of Preferences › Smart Guides › Construction Guides.
#[test]
fn construction_angle_sets() {
assert_eq!(construction_angles(DEFAULT_CONSTRUCTION_ANGLES), [0.0, 45.0, 90.0, 135.0]);
assert_eq!(construction_angles("90° Angles"), [0.0, 90.0]);
assert_eq!(construction_angles("30° Angles"), [30.0, 60.0, 120.0, 150.0]);
assert_eq!(construction_angles("90° & 45° & 30° Angles").len(), 8);
assert_eq!(construction_angles("nonsense"), construction_angles(DEFAULT_CONSTRUCTION_ANGLES));
}
/// The sorted targets find by bisection just what a scan of all of them finds, whatever
/// gathered them.
#[test]
fn bisection_finds_what_a_scan_finds() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, id) = doc_with_rect();
let l = d.layers[0].id;
for i in 0..60 {
let (x, y) = ((i * 37 % 450) as f64, (i * 53 % 430) as f64);
let tri = vectorcraft_geom::PathData::single(SubPath::polyline(
&[Point::new(x, y), Point::new(x + 13.0, y + 3.0), Point::new(x + 5.0, y + 17.0)],
true,
));
let n = d.alloc_id();
d.insert(Some(l), 1, Node::path(n, tri, Appearance::default_art())).unwrap();
}
let mut s = Selection::default();
s.set([id]);
s.anchors.insert(id, [(0, 1)].into_iter().collect());
for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None), Targets::for_anchor_drag(&d, &s)] {
for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) {
let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q)));
assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}");
for (lines, v) in [(&t.xs, q.x), (&t.ys, q.y)] {
let mut near: Vec<f64> = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect();
let mut all: Vec<f64> = lines.iter().map(|l| l.0).filter(|w| (w - v).abs() <= 6.0).collect();
near.sort_by(f64::total_cmp);
all.sort_by(f64::total_cmp);
assert_eq!(near, all);
}
}
}
}
/// A drawing tool's snapper reuses its targets while hovering over the same document state, and
/// gathers them again when it changes, and on every press.
#[test]
fn draw_snap_gathers_once_per_document_state() {
let (d, _) = doc_with_rect();
let empty = vectorcraft_doc::Document::new(500.0, 500.0);
let (s, p) = (Selection::default(), paint());
let at = Point::new(202.0, 198.0);
let mut snap = DrawSnap::default();
let c = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0));
assert_eq!(labels(snap.guides()), ["anchor"]);
// The same revision: the targets are the ones gathered (the document isn't read again).
let c = ToolContext { revision: (1, 1), ..cx(&empty, &s, &p) };
assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0));
// A new revision, or a press, reads it.
let c = ToolContext { revision: (1, 2), ..cx(&empty, &s, &p) };
assert_eq!(snap.hover(&c, at, &[], None), at);
assert!(snap.guides().is_empty());
let c = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
assert_eq!(snap.press(&c, at, &[], None), Point::new(200.0, 200.0));
// The drag keeps what the press gathered, though the document now shows its preview.
let c = ToolContext { revision: (1, 3), ..cx(&empty, &s, &p) };
assert_eq!(snap.drag(&c, at, None), Point::new(200.0, 200.0));
snap.clear();
assert!(snap.guides().is_empty());
// Snap to Grid wins over Smart Guides, Shift still keeping the angle.
let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
let from = Leave::segment(&c, Point::ZERO, true);
let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from));
assert!((q.y - 0.0).abs() < 1e-9, "{q:?}");
}
}
+10
View File
@@ -216,6 +216,10 @@ impl ScreenFrame {
/// Read-only context a tool sees.
pub struct ToolContext<'a> {
pub doc: &'a Document,
/// Which open document `doc` is (its process-unique id) and its revision, which moves with
/// every change of the document or the selection: what a tool keeps between pointer events
/// for the document as it is (the snap targets of hovering) is keyed on it.
pub revision: (u64, u64),
pub selection: &'a Selection,
/// Screen pixels per document point.
pub zoom: f64,
@@ -319,6 +323,10 @@ pub struct ToolContext<'a> {
/// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the
/// pointer, a dragged edge or a drawn point.
pub snapping_tolerance: f64,
/// Smart Guides → Construction Guides: the angles (degrees, counter-clockwise from the x axis
/// as seen on the page) of the lines through the point a drawn point leaves from, onto which a
/// point near one lands ([`guides::construction_angles`]); none with the option off.
pub construction_angles: &'static [f64],
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<ScreenFrame>,
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
@@ -631,6 +639,7 @@ pub(crate) mod testutil {
pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> {
ToolContext {
doc: d,
revision: (0, 0),
selection: s,
zoom: 1.0,
isolation: None,
@@ -675,6 +684,7 @@ pub(crate) mod testutil {
measurement_labels: true,
transform_tools_guides: true,
snapping_tolerance: 4.0,
construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES),
screen: None,
plane_widget: Some(Default::default()),
}
+59 -17
View File
@@ -16,6 +16,10 @@
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
//! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected
//! blend's spine a click adds a point (on a point no key object sits on: deletes it).
//! Each anchor placed snaps to Smart Guides ([`DrawSnap`]): onto the anchors, centres and paths of
//! the other art and of the path being drawn, into line with them, onto the construction guides
//! through the last anchor; with Shift held it slides along its 45° step into line. Hovering shows
//! where the next one would go.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
@@ -23,6 +27,7 @@ use vectorcraft_geom::{BezPath, Point};
use crate::direct::hit_handle;
use crate::draw2::AnchorTool;
use crate::guides::{DrawSnap, Leave};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
@@ -43,6 +48,8 @@ pub struct PenTool {
handle: Option<(NodeId, usize, usize, Point)>,
/// The Anchor Point tool, while an Alt gesture on a selected path's handle or anchor lasts.
convert: Option<AnchorTool>,
/// Smart Guides for the anchors placed (and the pointer hovering).
snap: DrawSnap,
}
/// An anchor being placed, as the press decided: where it is, the path it goes on (none: the
@@ -116,8 +123,6 @@ impl Tool for PenTool {
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
let (mut p, _) = if matches!(ev.kind, PointerKind::Down) { crate::guides::snap_draw(cx, ev.pos, &exclude) } else { (ev.pos, vec![]) };
let tol = cx.tol(5.0);
// The path a press began is the selected one by the next event.
if self.drawing && self.path.is_none() {
@@ -127,6 +132,14 @@ impl Tool for PenTool {
if self.drawing && active.is_none() && ev.kind == PointerKind::Down {
self.stop();
}
// The path being drawn moves its bounds as it grows: only its anchors and segments pull.
let drawn = active.map(|a| a.0);
let from = active.map(|(_, _, last, _)| Leave::segment(cx, last, ev.mods.shift));
let p = match ev.kind {
PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()),
PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()),
_ => ev.pos,
};
match ev.kind {
PointerKind::Move => {
self.hover = Some(p);
@@ -135,9 +148,6 @@ impl Tool for PenTool {
PointerKind::Down => {
self.last = ev.pos;
if let Some((id, first, last, _)) = active {
if ev.mods.shift {
p = last + vectorcraft_geom::constrain_angle(p - last, 45.0);
}
let end = last_anchor(cx, id);
// A one-anchor path doesn't close on itself: its anchor is the last one too.
if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) {
@@ -230,6 +240,7 @@ impl Tool for PenTool {
)]
}
PointerKind::Up => {
self.snap.clear();
if let Some(mut t) = self.convert.take() {
return t.pointer(cx, ev);
}
@@ -251,6 +262,7 @@ impl Tool for PenTool {
self.stop();
self.drag = None;
self.handle = None;
self.snap.clear();
// An Alt gesture under way ends as it stands.
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
@@ -259,25 +271,30 @@ impl Tool for PenTool {
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.stop();
self.snap.clear();
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(t) = &self.convert {
return t.overlays(cx);
}
if !cx.pen_rubber_band || self.drag.is_some() || self.handle.is_some() {
return vec![];
let mut o = vec![];
if cx.pen_rubber_band
&& self.drag.is_none()
&& self.handle.is_none()
&& let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover)
{
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
}
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
}
let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover) else { return vec![] };
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
}
let c = cx.doc.layer_color(id);
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
o.extend_from_slice(self.snap.guides());
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0);
@@ -614,6 +631,31 @@ mod tests {
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
}
/// Each anchor snaps to Smart Guides (#506): hovering shows where it goes (the rubber band ends
/// there), in line with another object's centre; Shift slides it along its 45° step into line.
#[test]
fn anchors_snap_to_smart_guides() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool { drawing: true, ..PenTool::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
let o = t.overlays(&cx);
assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}");
assert!(o.iter().any(
|o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0)))
));
let a = click(&mut t, &cx, 152.0, 318.0, Mods::default());
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0})));
let shift = Mods { shift: true, ..Mods::default() };
let a = click(&mut t, &cx, 152.0, 310.0, shift);
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0})));
// The first anchor of a new path lands on the rect's corner.
let mut t = PenTool::default();
let a = click(&mut t, &cx, 102.0, 99.0, Mods::default());
assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]})));
}
#[test]
fn alt_over_a_shown_handle_or_an_anchor_converts() {
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
+69 -23
View File
@@ -2,11 +2,13 @@
//!
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves
//! the shape being drawn); a click without a drag asks the UI for the size dialog (like
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count.
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count. The start point and
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use crate::guides::{DrawSnap, Leave, square};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct ShapeTool {
@@ -20,7 +22,7 @@ pub struct ShapeTool {
pub corner_radius: f64,
/// Star inner radius as a fraction of the outer radius.
pub star_ratio: f64,
guides: Vec<Overlay>,
snap: DrawSnap,
}
impl ShapeTool {
@@ -43,7 +45,7 @@ impl ShapeTool {
points: 5,
corner_radius: 12.0,
star_ratio: 0.5,
guides: vec![],
snap: DrawSnap::default(),
}
}
@@ -58,7 +60,18 @@ impl ShapeTool {
}
}
/// The command for the current drag.
/// How the dragged point keeps to the start: a polygon's or star's radius goes anywhere, a
/// line is a segment (Shift: 45° steps), the rest are boxes (Shift: a square).
fn leave(&self, cx: &ToolContext, start: Point, m: Mods) -> Option<Leave> {
match self.id {
"polygon" | "star" => None,
"lineSegment" => Some(Leave::segment(cx, start, m.shift)),
_ => Some(Leave::diagonal(start, m.shift)),
}
}
/// The command for a drag from `start` to `p` (snapped: a line's end already keeps Shift's
/// angle).
pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) {
match self.id {
"polygon" | "star" => {
@@ -76,7 +89,6 @@ impl ShapeTool {
}
"lineSegment" => {
let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) };
let b = if m.shift { a + vectorcraft_geom::constrain_angle(b - a, 45.0) } else { b };
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
}
_ => {
@@ -104,11 +116,7 @@ pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent
/// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its
/// centre.
pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect {
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
}
let d = if m.shift { square(p - start) } else { p - start };
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
}
@@ -121,8 +129,12 @@ impl Tool for ShapeTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.snap.hover(cx, ev.pos, &[], None);
vec![]
}
PointerKind::Down => {
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
let p = self.snap.press(cx, ev.pos, &[], None);
self.start = Some(p);
self.last = ev.pos;
self.began = false;
@@ -130,8 +142,7 @@ impl Tool for ShapeTool {
}
PointerKind::Drag => {
let Some(mut s) = self.start else { return vec![] };
let (pos, g) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.guides = g;
let pos = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref());
let ev = &PointerEvent { pos, ..*ev };
space_moves(&mut s, &mut self.last, ev, self.began);
self.start = Some(s);
@@ -150,7 +161,7 @@ impl Tool for ShapeTool {
}
PointerKind::Up => {
let Some(s) = self.start.take() else { return vec![] };
self.guides.clear();
self.snap.clear();
if self.began {
self.began = false;
vec![Action::Commit]
@@ -196,23 +207,26 @@ impl Tool for ShapeTool {
} else if key == ToolKey::Escape {
self.start = None;
self.began = false;
self.snap.clear();
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match self.start {
Some(s) if self.began => {
let d = self.last - s;
let mut o = self.guides.clone();
if cx.measurement_labels {
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
let mut o = self.snap.guides().to_vec();
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
// A box's size as drawn (from its centre with Alt, square with Shift).
let (w, h) = match self.id {
"polygon" | "star" | "lineSegment" => ((self.last - s).x.abs(), (self.last - s).y.abs()),
_ => {
let r = drag_rect(s, self.last, self.mods);
(r.width(), r.height())
}
o
}
_ => vec![],
};
o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) });
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
@@ -310,4 +324,36 @@ mod tests {
let a = t.key(&cx, ToolKey::Up, Mods::default());
assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7));
}
/// The start and the dragged corner land on another object's anchors (#506), hovering shows
/// it before the press, and the measurement label gives the size drawn.
#[test]
fn corners_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeTool::new("rectangle");
let anchor = |o: &[Overlay], at: Point| o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == at));
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 98.0, 102.0));
assert!(anchor(&t.overlays(&cx), Point::new(100.0, 100.0)));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 98.0, 102.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0));
assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 100.0, "y": 100.0, "width": 100.0, "height": 100.0})));
let o = t.overlays(&cx);
assert!(anchor(&o, Point::new(200.0, 200.0)), "{o:?}");
assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(100.0, 100.0))));
// Alt draws from the centre: the label gives the whole size.
let alt = Mods { alt: true, ..Mods::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0).with_mods(alt));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(200.0, 200.0))));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 198.0, 202.0));
assert!(t.overlays(&cx).is_empty());
// A line with Shift keeps to 45° steps, sliding into line with the rect's right side.
let mut t = ShapeTool::new("lineSegment");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let shift = Mods { shift: true, ..Mods::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 302.0).with_mods(shift));
assert!(matches!(&a[..], [_, Action::Preview(_, v)] if v["x2"] == 200.0 && (v["y2"].as_f64().unwrap() - 300.0).abs() < 1e-9), "{a:?}");
}
}
+24 -12
View File
@@ -3,7 +3,8 @@
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when
//! editing ends (`text.discardEmpty`); frames keep their shape.
//! editing ends (`text.discardEmpty`); frames keep their shape. Where new type goes (the click, the
//! frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
//!
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
@@ -27,6 +28,7 @@ use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
use vectorcraft_text::{FontDb, TextLayout, edit};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Which of the Type tools this is.
@@ -68,8 +70,12 @@ pub struct TypeTool {
goal_x: Option<f64>,
typing: Option<Typing>,
preedit: Option<Preedit>,
press: Option<Point>,
/// Where the button went down, and where new type goes from there (snapped to Smart Guides).
press: Option<(Point, Point)>,
/// The other corner of an area dragged out (snapped).
drag: Option<Point>,
/// Smart Guides for where new type goes.
snap: DrawSnap,
/// The press landed in the edited text: dragging selects.
selecting: bool,
/// Click counting for double/triple click (position of the last click, count).
@@ -252,7 +258,8 @@ impl TypeTool {
Some(out)
}
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
/// The button released after a press at `start` (new type goes at `at`).
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
// Click into existing text: place the caret.
if let Some(out) = self.edit_at(cx, start) {
return out;
@@ -272,10 +279,10 @@ impl TypeTool {
return out;
}
}
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
None => json!({"x": start.x, "y": start.y, "text": ""}),
None => json!({"x": at.x, "y": at.y, "text": ""}),
};
params["vertical"] = json!(self.vertical);
params["placeholder"] = json!(cx.placeholder_text);
@@ -386,7 +393,7 @@ impl Tool for TypeTool {
self.selecting = true;
return out;
}
self.press = Some(ev.pos);
self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None)));
self.drag = None;
vec![]
}
@@ -398,7 +405,7 @@ impl Tool for TypeTool {
self.clicks = (None, 0);
}
} else if self.press.is_some() {
self.drag = Some(ev.pos);
self.drag = Some(self.snap.drag(cx, ev.pos, None));
}
vec![]
}
@@ -407,8 +414,9 @@ impl Tool for TypeTool {
self.selecting = false;
return vec![];
}
let Some(start) = self.press.take() else { return vec![] };
self.on_up(cx, start)
self.snap.clear();
let Some((start, at)) = self.press.take() else { return vec![] };
self.on_up(cx, start, at)
}
PointerKind::DoubleClick => {
if self.editing.is_some() && self.hit_edited(cx, ev.pos).is_some() {
@@ -417,7 +425,11 @@ impl Tool for TypeTool {
}
vec![]
}
PointerKind::Move => vec![],
PointerKind::Move => {
// The type being edited is no target: its guides would only point at itself.
self.snap.hover(cx, ev.pos, self.editing.as_slice(), None);
vec![]
}
}
}
fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec<Action> {
@@ -700,8 +712,8 @@ impl Tool for TypeTool {
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
if let (Some(s), Some(d)) = (self.press, self.drag) {
let mut o = self.snap.guides().to_vec();
if let (Some((_, s)), Some(d)) = (self.press, self.drag) {
o.push(Overlay::Marquee(Rect::from_points(s, d)));
}
// Overflow markers on selected text.
+21
View File
@@ -335,3 +335,24 @@ fn hebrew_typing_and_visual_arrows_keep_logical_text() {
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "שלום");
}
/// Where new type goes snaps to Smart Guides (#506): a click beside a rect lines up with its
/// centre, and hovering shows it first; an area's corners land on its anchors.
#[test]
fn new_type_snaps_to_smart_guides() {
let (d, _) = crate::testutil::doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let mut tool = TypeTool::new("type");
tool.pointer(&c, &PointerEvent::new(PointerKind::Move, 151.0, 330.0));
assert!(tool.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 330.0))));
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 330.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 151.0, 330.0));
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 150.0 && v["y"] == 330.0)), "{acts:?}");
let mut tool = TypeTool::new("type");
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.0, 98.0));
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 199.0, 202.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 199.0, 202.0));
let area = json!({"width": 100.0, "height": 100.0});
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}");
}
+9 -10
View File
@@ -8,14 +8,14 @@
//! the Selection tool. Moving and resizing snap like drawing
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
//! snap too.
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, Vec2};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
use crate::guides::Targets;
use crate::guides::{DrawSnap, Leave, Targets};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug)]
@@ -57,11 +57,13 @@ pub struct ArtboardTool {
copy_targets: Option<Targets>,
/// Smart Guides shown while dragging.
guides: Vec<Overlay>,
/// Smart Guides for the corners of a new artboard.
draw: DrawSnap,
}
impl Default for ArtboardTool {
fn default() -> Self {
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![] }
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![], draw: DrawSnap::default() }
}
}
@@ -134,6 +136,7 @@ impl ArtboardTool {
self.targets = None;
self.copy_targets = None;
self.guides.clear();
self.draw.clear();
}
}
@@ -171,7 +174,7 @@ impl Tool for ArtboardTool {
self.active = i;
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
} else {
let (p, _) = crate::guides::snap_draw(cx, p, &[]);
let p = self.draw.press(cx, p, &[], None);
self.drag = Some(Drag::Create { start: p, cur: p });
}
vec![]
@@ -213,12 +216,7 @@ impl Tool for ArtboardTool {
out
}
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
let (mut cur, _) = crate::guides::snap_draw(cx, p, &[]);
if m.shift {
let d = cur - start;
let s = d.x.abs().max(d.y.abs());
cur = start + Vec2::new(s.copysign(d.x), s.copysign(d.y));
}
let cur = self.draw.drag(cx, p, Some(&Leave::diagonal(start, m.shift)));
self.drag = Some(Drag::Create { start, cur });
vec![]
}
@@ -289,6 +287,7 @@ impl Tool for ArtboardTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
if let Some(Drag::Create { start, cur }) = self.drag {
o.extend_from_slice(self.draw.guides());
let r = Rect::from_points(start, cur);
o.push(Overlay::Marquee(r));
if cx.measurement_labels {
+27 -3
View File
@@ -1215,8 +1215,8 @@ off, an empty window, and the Home button or `app.home` still shows the screen)
default: on, a click away from a panel popped out of the icon column puts it away).
The Smart Guides preferences (Preferences › Smart Guides) apply to `pointer_gesture` with Smart Guides on (the
default view) and to the mouse; they change what the tools show and how far a target pulls, never where a snapped
point lands:
default view) and to the mouse; they change what the tools show and how far a target pulls, and only Construction
Guides change where a snapped point lands:
- `smartGuideColor` (`#ff3dfc` by default): the colour of the smart guides' lines and labels.
- `alignmentGuides` (on by default): off, no line is drawn along the edge or centre the art lines up with; the art
@@ -1231,6 +1231,11 @@ point lands:
- `snappingTolerance` (0–40 px, 4 by default): how near an anchor, edge, centre or artboard edge pulls a drawn point,
a dragged selection, a bounding-box handle, a ruler guide or an artboard. With Smart Guides off, Snap to Point uses
`snapToPointTolerance` instead.
- `constructionGuides` (on by default) and `constructionAngles` (`90° & 45° Angles` by default; also `90° Angles`,
`45° Angles`, `60° Angles`, `30° Angles`, `90° & 45° & 30° Angles`): a point drawn near a line at one of those
angles through the point it leaves from (the Pen's or Curvature tool's last anchor, a line's start) lands on that
line, where a target's alignment line crosses it if one does nearby. "90°" gives the horizontal and the vertical,
"45°" the diagonals, "60°" and "30°" the multiples of those angles in between. Off, the point only lines up.
With Smart Guides on, a bounding-box handle dragged with the Selection or Free Transform tool (`mods.shift`
proportional, `mods.alt` from the centre) lands on another object's anchor or centre (labelled "anchor" or
@@ -1242,7 +1247,26 @@ snap the pointer as drawing tools do, so dragging a corner onto another object's
{"name":"pointer_gesture","arguments":{"tool":"scale","events":[{"kind":"down","x":100,"y":100},{"kind":"up","x":100,"y":100},{"kind":"down","x":199,"y":198},{"kind":"drag","x":302,"y":262},{"kind":"up","x":302,"y":262}]}}
```
Not read yet: Construction Guides and their Angles, and Spacing Guides (the tools draw neither).
Every point a drawing tool places snaps the same way, through one snapper (#506): the Pen's and Curvature tool's
anchors, the Pencil's first and last point, the shape tools' start and dragged corner or radius (`rectangle`,
`roundedRectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, `arc`, `spiral`, `rectangularGrid`, `polarGrid`), the
Type tool's click and frame, and a new artboard's corners. The point lands on another object's anchor, centre or
path ("anchor", "center", "path"), else lines up with the anchors, edges and centres of the art and the artboards (a
line from the target and "align"); the Pen's lands on the path being drawn too, so a click on its first anchor closes
it exactly. With `mods.shift` the point keeps to its 45° step from the anchor before it (from `constrainAngle`; a box
dragged keeps its corner on a diagonal) and slides along that way into line with the nearest target. A `move` event
before the press shows where it would go, as hovering does with the mouse. The targets are the art in the window (all
of it headless), gathered once per document state; the shape being drawn is never one of them:
```json
{"name":"pointer_gesture","arguments":{"tool":"rectangle","events":[{"kind":"down","x":202,"y":203},{"kind":"drag","x":330,"y":341},{"kind":"up","x":330,"y":341}]}}
{"name":"pointer_gesture","arguments":{"tool":"pen","events":[{"kind":"down","x":300,"y":420},{"kind":"up","x":300,"y":420},{"kind":"down","x":151.5,"y":431},{"kind":"up","x":151.5,"y":431}]}}
```
With a 100 × 100 square at (100, 100), the first starts the rectangle on its corner (200, 200); the second puts the
Pen's second anchor at (150, 431), in line with the square's centre.
Not read yet: Spacing Guides (the tools draw none).
## Type preferences