mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Part of #451: guides dragged out of the rulers snap, artboard guides
Guides dragged out of a ruler did not snap at all (only Shift to the ruler's
ticks): they now go through the engine (`Session::ruler_guide`, a
`NewGuide` previewing `guide.add`, one undo step) and snap as a moved guide
does: with Smart Guides to the edges, centres (so the sides' midpoints) and
anchors of the art and of the artboards, labelled; with Smart Guides off,
View > Snap to Point pulls new and moved guides onto anchors. Moving art
with the Selection tool already snapped flush to artboard edges and centres
with Smart Guides on; a test now covers it.
Artboard guides: `Guide.artboard` holds an artboard id (written only when
set, so canvas guides save as before and older files open unchanged).
`guide.add {artboard}` makes one, `guide.list` reports it; a guide dragged
out of a ruler with the Artboard tool belongs to the active artboard. It is
drawn, picked and snapped to across its artboard only, moves with it
(`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`),
is copied with it (`artboard.duplicate`, Alt-drag copy) and deleted with it
(`artboard.delete`, the guide selection kept in step).
Guides as Layers rows with their own lock and hide (part d) stay open: ruler
guides are not layer nodes; Lock Guides locks them all, and Make Guides
paths are Layers rows already. Listed in the ROADMAP parity row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5f9d280892
commit
5d65699043
19 files changed
+670
-117
No files matched your search
+3
-3
@@ -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); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, and artboard guides (#451); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -128,7 +128,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: the dragged edges align with the other objects; 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 (Shift snaps them to the ruler's ticks) 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 art's edges, centres and 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: the dragged edges align with the other objects; 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.
|
||||
- **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).
|
||||
- **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.
|
||||
@@ -210,7 +210,7 @@ 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 (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 | 80% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click) and on layers (dragging existing guides is done, #414) | 3–6 |
|
||||
| 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 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against more real files (two Illustrator 30 files open as vectors so far), 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 |
|
||||
|
||||
+58
-1
@@ -86,7 +86,7 @@ pub use vectorcraft_geom as geom;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum DocError {
|
||||
@@ -386,6 +386,22 @@ pub struct Guide {
|
||||
/// true = vertical guide at `pos` (x), false = horizontal at `pos` (y).
|
||||
pub vertical: bool,
|
||||
pub pos: f64,
|
||||
/// An artboard guide: the [`Artboard::id`] it belongs to. It runs across that artboard only
|
||||
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artboard: Option<u32>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// A canvas guide.
|
||||
pub fn new(vertical: bool, pos: f64) -> Self {
|
||||
Self { vertical, pos, artboard: None }
|
||||
}
|
||||
|
||||
/// Moved by `d` (a vertical guide across, a horizontal one down).
|
||||
pub fn moved(&self, d: Vec2) -> Self {
|
||||
Self { pos: self.pos + if self.vertical { d.x } else { d.y }, ..self.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -940,6 +956,47 @@ impl Document {
|
||||
}
|
||||
art
|
||||
}
|
||||
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
|
||||
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
|
||||
/// artboard is gone.
|
||||
pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> {
|
||||
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
|
||||
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
|
||||
}
|
||||
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
|
||||
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
|
||||
let along = if g.vertical { p.y } else { p.x };
|
||||
self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol)
|
||||
}
|
||||
/// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their
|
||||
/// place among the rest (the selected art stays selected). Returns how many went.
|
||||
pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize {
|
||||
let kept: Vec<bool> = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect();
|
||||
// Where each kept guide ends up.
|
||||
let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0);
|
||||
for k in &kept {
|
||||
to.push(k.then_some(n));
|
||||
n += usize::from(*k);
|
||||
}
|
||||
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
|
||||
let before = self.guides.len();
|
||||
let mut flags = kept.into_iter();
|
||||
self.guides.retain(|_| flags.next().unwrap_or(true));
|
||||
before - self.guides.len()
|
||||
}
|
||||
/// Move the guides of artboard `id` by `d` (along with their artboard).
|
||||
pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) {
|
||||
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
|
||||
*g = g.moved(d);
|
||||
}
|
||||
}
|
||||
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
|
||||
/// artboard).
|
||||
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {
|
||||
let copies: Vec<Guide> =
|
||||
self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect();
|
||||
self.guides.extend(copies);
|
||||
}
|
||||
pub fn next_artboard_id(&self) -> u32 {
|
||||
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ mod tests {
|
||||
assert!(s.guides.is_empty());
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide { vertical: true, pos: 10.0 });
|
||||
d.guides.push(crate::Guide::new(true, 10.0));
|
||||
s.set_guides([0, 1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection, TextKind};
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
@@ -154,8 +155,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
clear_guides
|
||||
),
|
||||
cmd!("guide.add", "Add Guide", [], None, "{vertical: bool, pos: pt (x for vertical, y for horizontal)} → {index}", has_doc, guide_add),
|
||||
cmd!(query "guide.list", "Guides", [], None, "{} → [{index, vertical, pos, selected}…] the ruler guides", has_doc, guide_list),
|
||||
cmd!(
|
||||
"guide.add",
|
||||
"Add Guide",
|
||||
[],
|
||||
None,
|
||||
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}",
|
||||
has_doc,
|
||||
guide_add
|
||||
),
|
||||
cmd!(
|
||||
query "guide.list",
|
||||
"Guides",
|
||||
[],
|
||||
None,
|
||||
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides",
|
||||
has_doc,
|
||||
guide_list
|
||||
),
|
||||
cmd!(
|
||||
"guide.select",
|
||||
"Select Guides",
|
||||
@@ -481,8 +498,16 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pos = f64_req(p, "pos", "guide.add")?;
|
||||
let vertical = bool_or(p, "vertical", false);
|
||||
let artboard = match p.get("artboard").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let boards = &s.doc()?.doc.artboards;
|
||||
let ab = v.as_u64().and_then(|i| boards.get(usize::try_from(i).ok()?)).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
Some(ab.id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let i = s.edit("New Guide", |d, _| {
|
||||
d.guides.push(Guide { vertical, pos });
|
||||
d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) });
|
||||
Ok(d.guides.len() - 1)
|
||||
})?;
|
||||
Ok(json!({ "index": i }))
|
||||
@@ -490,7 +515,13 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let row = |(i, g): (usize, &Guide)| json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) });
|
||||
let row = |(i, g): (usize, &Guide)| {
|
||||
let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) });
|
||||
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
|
||||
r["artboard"] = json!(ab);
|
||||
}
|
||||
r
|
||||
};
|
||||
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
|
||||
}
|
||||
|
||||
@@ -533,11 +564,7 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" };
|
||||
s.edit(label, |d, sel| {
|
||||
let kept = |i: &usize| !gone.contains(i);
|
||||
// The guides left selected keep their place among the rest.
|
||||
let left: Vec<usize> = sel.guides.iter().filter(|i| kept(i)).map(|i| i - gone.iter().filter(|g| *g < i).count()).collect();
|
||||
d.guides = std::mem::take(&mut d.guides).into_iter().enumerate().filter(|(i, _)| kept(i)).map(|(_, g)| g).collect();
|
||||
sel.set_guides(left);
|
||||
d.retain_guides(sel, |i, _| !gone.contains(&i));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": gone.len() }))
|
||||
@@ -565,7 +592,7 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut copies = vec![];
|
||||
for &i in &moving {
|
||||
let Some(g) = d.guides.get_mut(i) else { continue };
|
||||
let moved = Guide { vertical: g.vertical, pos: g.pos + if g.vertical { dx } else { dy } };
|
||||
let moved = g.moved(Vec2::new(dx, dy));
|
||||
if copy {
|
||||
copies.push(moved);
|
||||
} else {
|
||||
|
||||
@@ -735,14 +735,16 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
|
||||
s.edit("Delete Artboard", |d, _| {
|
||||
s.edit("Delete Artboard", |d, sel| {
|
||||
if d.artboards.len() <= 1 {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
if i >= d.artboards.len() {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
d.artboards.remove(i);
|
||||
// Its guides go with it.
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
@@ -752,10 +754,16 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let was = a.rect;
|
||||
a.rect = rect_from(p, a.rect);
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
a.name = n.to_string();
|
||||
}
|
||||
// Moved (not resized), it takes its guides along.
|
||||
let (id, now) = (a.id, a.rect);
|
||||
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
|
||||
d.move_artboard_guides(id, now.origin() - was.origin());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -769,8 +769,13 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut moved = vec![];
|
||||
for (a, dl) in d.artboards.iter_mut().zip(&deltas) {
|
||||
a.rect = a.rect + *dl;
|
||||
moved.push((a.id, *dl));
|
||||
}
|
||||
for (id, dl) in moved {
|
||||
d.move_artboard_guides(id, dl);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -118,7 +118,9 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val
|
||||
if i >= d.artboards.len() {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
d.artboards.remove(i);
|
||||
// Its guides go with it.
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
for id in &art {
|
||||
// Always there: none of the art is inside another of it.
|
||||
let _ = d.remove(*id);
|
||||
@@ -134,8 +136,9 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val
|
||||
Ok(json!({"copied": copied}))
|
||||
}
|
||||
|
||||
/// Add a copy of artboard `i` moved by `dv`, with copies of its `art` (each just above its
|
||||
/// original) moved along → (the copy's index, the art's copies). Duplicate Artboards and Alt-drag.
|
||||
/// Add a copy of artboard `i` moved by `dv`, with copies of its guides and of its `art` (each just
|
||||
/// above its original) moved along → (the copy's index, the art's copies). Duplicate Artboards and
|
||||
/// Alt-drag.
|
||||
pub(crate) fn copy_artboard(
|
||||
d: &mut vectorcraft_doc::Document,
|
||||
i: usize,
|
||||
@@ -145,6 +148,10 @@ pub(crate) fn copy_artboard(
|
||||
) -> Result<(usize, Vec<NodeId>)> {
|
||||
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let index = push_artboard_copy(d, &src, src.rect + dv);
|
||||
// Its guides come along.
|
||||
if let Some(id) = d.artboards.get(index).map(|a| a.id) {
|
||||
d.copy_artboard_guides(src.id, id, dv);
|
||||
}
|
||||
let mut copies = Vec::with_capacity(art.len());
|
||||
for id in art {
|
||||
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
|
||||
|
||||
@@ -381,6 +381,8 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + dv;
|
||||
let id = a.id;
|
||||
d.move_artboard_guides(id, dv);
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
|
||||
@@ -774,6 +774,8 @@ pub struct Session {
|
||||
pub(crate) liquify_stroke: Option<Box<cmd::distortcmds::LiquifyStroke>>,
|
||||
/// A press on the Plane Switching Widget is under way: its drag and release are the widget's.
|
||||
pub(crate) plane_widget_press: bool,
|
||||
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
|
||||
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -818,6 +820,7 @@ impl Session {
|
||||
envelope_defaults: None,
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
ruler_guide: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -799,6 +799,126 @@ fn ruler_guides_select_move_and_delete() {
|
||||
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
/// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved,
|
||||
/// copied and deleted with it; canvas guides stay put.
|
||||
#[test]
|
||||
fn artboard_guides_go_with_their_artboard() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
|
||||
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
list(&mut s),
|
||||
json!([
|
||||
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1},
|
||||
{"index": 1, "vertical": false, "pos": 100.0, "selected": false},
|
||||
])
|
||||
);
|
||||
let spans = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.guides.iter().map(|g| d.guide_span(g)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(spans(&s), [Some((0.0, 300.0)), None]);
|
||||
for bad in [json!(5), json!("x"), json!(-1)] {
|
||||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10, "artboard": bad})).is_err(), "{bad}");
|
||||
}
|
||||
// Moved, the artboard takes its guides along; in Artboard Options too, unless resized.
|
||||
s.execute("artboard.move", &json!({"index": 1, "dx": 50, "dy": 20})).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 1050.0), (false, 100.0)]);
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "x": 960.5})).unwrap();
|
||||
assert_eq!(guides(&s)[0], (true, 1060.5));
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "x": 900, "width": 500})).unwrap();
|
||||
assert_eq!((guides(&s)[0], spans(&s)[0]), ((true, 1060.5), Some((20.0, 320.0))));
|
||||
// Renumbered, it keeps them.
|
||||
s.execute("artboard.reorder", &json!({"index": 1, "to": 0})).unwrap();
|
||||
assert_eq!(list(&mut s)[0]["artboard"], 0);
|
||||
// Duplicated (or Alt-dragged), the copy gets copies of them.
|
||||
let dup = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap()["index"].as_u64().unwrap();
|
||||
let dx = s.doc().unwrap().doc.artboards[dup as usize].rect.x0 - 900.0;
|
||||
assert_eq!(guides(&s), [(true, 1060.5), (false, 100.0), (true, 1060.5 + dx)]);
|
||||
assert_eq!(list(&mut s)[2]["artboard"], dup);
|
||||
s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 400, "copy": true})).unwrap();
|
||||
assert_eq!((guides(&s).len(), list(&mut s)[3]["artboard"].clone()), (4, json!(3)));
|
||||
// Rearranged, they follow their artboards.
|
||||
s.execute("artboard.rearrange", &json!({"columns": 4})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
for (g, ab) in d.guides.iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) {
|
||||
assert_eq!(g.pos - ab.rect.x0, 160.5, "{g:?} on {ab:?}");
|
||||
}
|
||||
// Deleted, the artboard takes its guides; the selected ones left stay selected.
|
||||
s.execute("guide.select", &json!({"indexes": [1, 2]})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
|
||||
assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true}));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
|
||||
// Cut, too.
|
||||
s.execute("artboard.cut", &json!({"index": 0})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
|
||||
}
|
||||
|
||||
/// #451: a guide dragged out of a ruler snaps (to the art's corners, side midpoints and anchors,
|
||||
/// the artboards' edges and centres) and is made in one undo step where it is released over the
|
||||
/// canvas; with the Artboard tool it is an artboard guide of the active artboard.
|
||||
#[test]
|
||||
fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
|
||||
use vectorcraft_tools::{Overlay, PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let v = ViewInfo::default();
|
||||
let ev = |kind, x, y| PointerEvent::new(kind, x, y);
|
||||
let n = undo_len(&s);
|
||||
s.ruler_guide(true, &ev(PointerKind::Drag, -10.0, 30.0), false, v).unwrap();
|
||||
assert!(guides(&s).is_empty(), "still over the ruler");
|
||||
s.ruler_guide(true, &ev(PointerKind::Drag, 153.0, 30.0), true, v).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 150.0)], "onto the line through the top and bottom midpoints");
|
||||
assert!(s.overlays(v).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "center")));
|
||||
s.ruler_guide(true, &ev(PointerKind::Drag, 797.0, 30.0), true, v).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 800.0)], "the artboard's edge");
|
||||
s.ruler_guide(true, &ev(PointerKind::Up, 797.0, 30.0), true, v).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 800.0)]);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "New Guide");
|
||||
assert!(s.overlays(v).is_empty());
|
||||
// Released off the canvas: none.
|
||||
s.ruler_guide(false, &ev(PointerKind::Drag, 30.0, 250.0), true, v).unwrap();
|
||||
s.ruler_guide(false, &ev(PointerKind::Up, 30.0, -10.0), false, v).unwrap();
|
||||
assert_eq!((guides(&s).len(), undo_len(&s)), (1, n + 1));
|
||||
// The Artboard tool: an artboard guide of its active artboard.
|
||||
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
s.select_tool("artboard", v).unwrap();
|
||||
s.set_tool_option("active", &json!(1));
|
||||
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
|
||||
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
|
||||
assert_eq!(
|
||||
s.execute("guide.list", &json!({})).unwrap()[1],
|
||||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1})
|
||||
);
|
||||
}
|
||||
|
||||
/// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart
|
||||
/// Guides).
|
||||
#[test]
|
||||
fn moved_art_snaps_to_the_artboard_edges_and_centre() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let mut drag = |from: (f64, f64), to: (f64, f64)| {
|
||||
for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
|
||||
}
|
||||
let b = bounds(&s, a);
|
||||
(b.x0, b.y0)
|
||||
};
|
||||
assert_eq!(drag((150.0, 150.0), (53.0, 147.0)), (0.0, 97.0), "3 pt off the left edge: flush with it");
|
||||
assert_eq!(drag((50.0, 147.0), (402.0, 298.0)), (350.0, 250.0), "its centre on the artboard's");
|
||||
}
|
||||
|
||||
/// #414: the Selection tool picks a ruler guide over the art and drags it in one undo step; with
|
||||
/// guides hidden or locked the press goes to the art.
|
||||
#[test]
|
||||
|
||||
@@ -206,6 +206,30 @@ impl Session {
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
|
||||
/// horizontal one out of the top ruler. `ev` is the pointer in document space (a drag, then the
|
||||
/// release), over the canvas or not (`on_canvas`). The guide shows where the pointer is over
|
||||
/// the canvas, snapped as a moved guide is, and is made where the button is released over it
|
||||
/// (one undo step); released anywhere else, none is. With the Artboard tool it is an artboard
|
||||
/// guide of the active artboard.
|
||||
pub fn ruler_guide(&mut self, vertical: bool, ev: &PointerEvent, on_canvas: bool, view: ViewInfo) -> Result<()> {
|
||||
let g = match self.ruler_guide.take() {
|
||||
Some(g) => Some(g),
|
||||
None => {
|
||||
let boards = self.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let active = (self.tool.id() == "artboard").then(|| self.tool.options()["active"].as_u64()).flatten();
|
||||
let artboard = active.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < boards);
|
||||
self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::NewGuide::new(cx, vertical, artboard)))
|
||||
}
|
||||
};
|
||||
let Some(mut g) = g else { return Ok(()) };
|
||||
let acts = self.with_tool_cx(view, |_, cx| g.pointer(cx, ev, on_canvas));
|
||||
if ev.kind != PointerKind::Up {
|
||||
self.ruler_guide = Some(g);
|
||||
}
|
||||
self.apply_actions(acts).map(drop)
|
||||
}
|
||||
|
||||
/// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the
|
||||
/// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event.
|
||||
pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
@@ -273,6 +297,10 @@ impl Session {
|
||||
|
||||
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
|
||||
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
|
||||
if let Some(g) = self.ruler_guide.take() {
|
||||
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
|
||||
self.ruler_guide = Some(g);
|
||||
}
|
||||
if let Some(d) = self.active() {
|
||||
let w = self.prefs.perspective_widget;
|
||||
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
|
||||
|
||||
@@ -184,3 +184,19 @@ fn pretty_files_are_indented_like_serde_json() {
|
||||
// The same JSON as the compact file.
|
||||
assert_eq!(json_of(&bytes), json_of(&save(&d, false)));
|
||||
}
|
||||
|
||||
/// #451: a guide's artboard is written only for artboard guides, so canvas guides save as they
|
||||
/// did, and a file without it (an older version's) opens with canvas guides.
|
||||
#[test]
|
||||
fn artboard_guides_round_trip_and_canvas_guides_save_as_before() {
|
||||
use vectorcraft_doc::Guide;
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let id = d.artboards[0].id;
|
||||
d.guides = vec![Guide::new(true, 10.0), Guide { artboard: Some(id), ..Guide::new(false, 20.0) }];
|
||||
let mut v = json_of(&save(&d, false));
|
||||
assert_eq!(v["document"]["guides"], json!([{"vertical": true, "pos": 10.0}, {"vertical": false, "pos": 20.0, "artboard": id}]));
|
||||
assert_eq!(load(&save(&d, false)).unwrap().guides, d.guides);
|
||||
v["document"]["guides"] = json!([{"vertical": false, "pos": 20.0}]);
|
||||
let old = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
assert_eq!(old.guides, [Guide::new(false, 20.0)]);
|
||||
}
|
||||
@@ -655,7 +655,7 @@ mod tests {
|
||||
#[test]
|
||||
fn direct_and_group_selection_pick_ruler_guides() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide { vertical: true, pos: 100.0 });
|
||||
d.guides.push(vectorcraft_doc::Guide::new(true, 100.0));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y));
|
||||
|
||||
+50
-19
@@ -44,6 +44,32 @@ pub struct Targets {
|
||||
/// Path segments and their control bounds, which a point lands on ("path"): gathered only for
|
||||
/// dragged anchors ([`Self::for_anchor_drag`]).
|
||||
segments: Vec<(Rect, PathSeg)>,
|
||||
/// Ruler guides, which pull a point into line where they run (Snap to Point).
|
||||
rulers: Vec<Ruler>,
|
||||
}
|
||||
|
||||
/// A ruler guide as a snap target.
|
||||
struct Ruler {
|
||||
vertical: bool,
|
||||
pos: f64,
|
||||
/// Where it runs along its line: an artboard guide across its artboard (see
|
||||
/// [`vectorcraft_doc::Document::guide_span`]), else (None) the whole canvas.
|
||||
span: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl Ruler {
|
||||
/// The line it pulls `p` into within `tol` of its ends (its position, where it starts, the
|
||||
/// kind), as [`Targets::xs`] holds them: none where it doesn't run.
|
||||
fn line_at(&self, p: Point, tol: f64) -> Option<(f64, Point, Kind)> {
|
||||
let along = if self.vertical { p.y } else { p.x };
|
||||
let start = match self.span {
|
||||
Some((a, b)) if along < a - tol || along > b + tol => return None,
|
||||
Some((a, _)) => a,
|
||||
None => 0.0,
|
||||
};
|
||||
let from = if self.vertical { Point::new(self.pos, start) } else { Point::new(start, self.pos) };
|
||||
Some((self.pos, from, Kind::Guide))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many anchors, and how many segments, the targets gather at most.
|
||||
@@ -151,22 +177,25 @@ impl Targets {
|
||||
t
|
||||
}
|
||||
|
||||
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
|
||||
/// unlocked, as the selection tools pick them) pull the pointer into line.
|
||||
fn for_snap_to_point(mut self, cx: &ToolContext) -> Self {
|
||||
/// Only these targets' anchors pull (View → Snap to Point).
|
||||
pub(crate) fn anchors_only(mut self) -> Self {
|
||||
self.points.retain(|(_, k)| *k == Kind::Anchor);
|
||||
self.xs.clear();
|
||||
self.ys.clear();
|
||||
self.segments.clear();
|
||||
if cx.guides {
|
||||
for g in &cx.doc.guides {
|
||||
let (lines, at) = if g.vertical { (&mut self.xs, Point::new(g.pos, 0.0)) } else { (&mut self.ys, Point::new(0.0, g.pos)) };
|
||||
lines.push((g.pos, at, Kind::Guide));
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
|
||||
/// unlocked, as the selection tools pick them) pull the pointer into line where they run.
|
||||
fn for_snap_to_point(self, cx: &ToolContext) -> Self {
|
||||
let mut t = self.anchors_only();
|
||||
if cx.guides {
|
||||
t.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
|
||||
/// Guides off: the anchors of the art but `exclude` and the ruler guides.
|
||||
pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option<Self> {
|
||||
@@ -206,17 +235,19 @@ impl Targets {
|
||||
}
|
||||
let mut out = p;
|
||||
let mut ov = vec![];
|
||||
if let Some((x, from, _)) =
|
||||
self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs()))
|
||||
{
|
||||
out.x = *x;
|
||||
ov.push(Overlay::Line { a: *from, b: Point::new(*x, p.y), color: MAGENTA, dashed: false });
|
||||
// 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()))
|
||||
};
|
||||
if let Some((x, from, _)) = nearest(&self.xs, true, p.x) {
|
||||
out.x = x;
|
||||
ov.push(Overlay::Line { a: from, b: Point::new(x, p.y), color: MAGENTA, dashed: false });
|
||||
}
|
||||
if let Some((y, from, _)) =
|
||||
self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs()))
|
||||
{
|
||||
out.y = *y;
|
||||
ov.push(Overlay::Line { a: *from, b: Point::new(p.x, *y), color: MAGENTA, dashed: false });
|
||||
if let Some((y, from, _)) = nearest(&self.ys, false, p.y) {
|
||||
out.y = y;
|
||||
ov.push(Overlay::Line { a: from, b: Point::new(p.x, y), color: MAGENTA, dashed: false });
|
||||
}
|
||||
if !ov.is_empty() {
|
||||
ov.push(Overlay::Label { p: out, text: "align".into(), color: MAGENTA });
|
||||
|
||||
+229
-40
@@ -1,9 +1,11 @@
|
||||
//! Ruler guides on the canvas, for the selection tools. A press on a guide selects it (Shift adds
|
||||
//! it to the selected guides or, released without a drag, takes it out); dragging moves the
|
||||
//! selected guides (Alt copies them). The dragged guide snaps: with Shift to the ruler's ticks,
|
||||
//! else as a drawn point does (whole pixels, the grid, or with Smart Guides the art's edges,
|
||||
//! centres and anchors). Dropped off the canvas onto its ruler, the guide is deleted. Hidden or
|
||||
//! locked guides (View › Guides) can't be picked.
|
||||
//! Ruler guides on the canvas. Dragged out of a ruler with any tool ([`NewGuide`]), a guide is
|
||||
//! made where it is released over the canvas: with the Artboard tool, an artboard guide of the
|
||||
//! active artboard. For the selection tools ([`GuideEdit`]), a press on a guide selects it (Shift
|
||||
//! adds it to the selected guides or, released without a drag, takes it out); dragging moves the
|
||||
//! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the
|
||||
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
|
||||
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
|
||||
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides) can't be picked.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -22,6 +24,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
.guides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, g)| cx.doc.guide_passes(g, p, tol))
|
||||
.map(|(i, g)| (i, off(g.pos, g.vertical)))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0)))
|
||||
@@ -47,15 +50,120 @@ struct Drag {
|
||||
deselect: bool,
|
||||
}
|
||||
|
||||
/// Snapping for a dragged ruler guide, its targets gathered when the drag begins.
|
||||
#[derive(Default)]
|
||||
struct GuideSnap {
|
||||
/// With Smart Guides the art's and the artboards' edges, centres and anchors, else with Snap
|
||||
/// to Point the anchors, and how near (in screen pixels) they pull; none with pixels or the
|
||||
/// grid snapping instead, or nothing on.
|
||||
targets: Option<(Targets, f64)>,
|
||||
/// The target the guide snapped to.
|
||||
snapped: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl GuideSnap {
|
||||
fn new(cx: &ToolContext) -> Self {
|
||||
let targets = || Targets::collect(cx.doc, &[], None);
|
||||
let targets = if cx.snap_to_pixel || cx.snap_to_grid {
|
||||
None
|
||||
} else if cx.smart_guides {
|
||||
Some((targets(), 5.0))
|
||||
} else if cx.snap_to_point {
|
||||
Some((targets().anchors_only(), cx.snap_tolerance))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self { targets, snapped: vec![] }
|
||||
}
|
||||
|
||||
/// Where a guide dragged to `v` (the x of a vertical one, the y of a horizontal one) goes:
|
||||
/// with Shift on the nearest ruler tick, else on whole pixels, the grid or a target.
|
||||
fn snap(&mut self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> f64 {
|
||||
self.snapped.clear();
|
||||
if shift {
|
||||
return cx.unit.snap_to_ruler_tick(v, cx.zoom);
|
||||
}
|
||||
if cx.snap_to_pixel {
|
||||
return v.round();
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return vectorcraft_geom::snap::snap_to_grid(v, cx.grid_step(), 0.0);
|
||||
}
|
||||
let Some((t, tol)) = &self.targets else { return v };
|
||||
let (at, snapped) = t.snap_guide(vertical, v, cx.tol(*tol));
|
||||
self.snapped = snapped;
|
||||
at
|
||||
}
|
||||
|
||||
/// The target the guide at `at` snapped to, and its position by the `pointer`.
|
||||
fn overlays(&self, cx: &ToolContext, vertical: bool, at: f64, pointer: Point) -> Vec<Overlay> {
|
||||
let mut o = self.snapped.clone();
|
||||
let axis = if vertical { "X" } else { "Y" };
|
||||
o.push(Overlay::Measure { p: pointer, text: format!("{axis}: {}", cx.len(at)) });
|
||||
o
|
||||
}
|
||||
}
|
||||
|
||||
/// A guide dragged out of a ruler (a vertical one out of the left ruler), with any tool: it shows
|
||||
/// where the pointer is, snapped as a moved guide is, and is made where the button is released
|
||||
/// over the canvas (`guide.add`, previewed as it goes); taken off the canvas, it goes again.
|
||||
pub struct NewGuide {
|
||||
vertical: bool,
|
||||
/// The artboard (index) it belongs to: an artboard guide.
|
||||
artboard: Option<usize>,
|
||||
snap: GuideSnap,
|
||||
/// Where it shows (none off the canvas), and the pointer.
|
||||
at: Option<f64>,
|
||||
pointer: Point,
|
||||
}
|
||||
|
||||
impl NewGuide {
|
||||
/// A vertical guide out of the left ruler, else a horizontal one out of the top ruler; an
|
||||
/// artboard guide of `artboard`, if any.
|
||||
pub fn new(cx: &ToolContext, vertical: bool, artboard: Option<usize>) -> Self {
|
||||
Self { vertical, artboard, snap: GuideSnap::new(cx), at: None, pointer: Point::ZERO }
|
||||
}
|
||||
|
||||
/// The pointer at `ev`, over the canvas or not (`on_canvas`): the actions that show the guide
|
||||
/// there or take it away, and on release make it.
|
||||
pub fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent, on_canvas: bool) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
self.pointer = ev.pos;
|
||||
if on_canvas {
|
||||
let at = self.snap.snap(cx, self.vertical, if self.vertical { ev.pos.x } else { ev.pos.y }, ev.mods.shift);
|
||||
if self.at.is_none() {
|
||||
out.push(Action::Begin("New Guide".into()));
|
||||
}
|
||||
if self.at != Some(at) {
|
||||
let mut p = json!({ "vertical": self.vertical, "pos": at });
|
||||
if let Some(ab) = self.artboard {
|
||||
p["artboard"] = json!(ab);
|
||||
}
|
||||
out.push(Action::Preview("guide.add".into(), p));
|
||||
}
|
||||
self.at = Some(at);
|
||||
} else if self.at.take().is_some() {
|
||||
self.snap.snapped.clear();
|
||||
out.push(Action::Cancel);
|
||||
}
|
||||
if ev.kind == PointerKind::Up && self.at.take().is_some() {
|
||||
out.push(Action::Commit);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// While the guide shows: the target it snapped to and its position by the pointer.
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
self.at.map(|at| self.snap.overlays(cx, self.vertical, at, self.pointer)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for
|
||||
/// the rest of the gesture.
|
||||
#[derive(Default)]
|
||||
pub struct GuideEdit {
|
||||
drag: Option<Drag>,
|
||||
/// Smart Guides targets, gathered when the drag starts.
|
||||
targets: Option<Targets>,
|
||||
/// The smart guide the dragged guide snapped to.
|
||||
snapped: Vec<Overlay>,
|
||||
snap: GuideSnap,
|
||||
}
|
||||
|
||||
impl GuideEdit {
|
||||
@@ -80,8 +188,7 @@ impl GuideEdit {
|
||||
began: false,
|
||||
deselect: shift && selected,
|
||||
});
|
||||
self.targets = None;
|
||||
self.snapped.clear();
|
||||
self.snap = GuideSnap::default();
|
||||
Some(match (selected, shift) {
|
||||
(true, _) => vec![],
|
||||
(false, true) => vec![Action::Exec("guide.select".into(), json!({ "indexes": [index], "toggle": true }))],
|
||||
@@ -100,8 +207,7 @@ impl GuideEdit {
|
||||
}
|
||||
PointerKind::Up => {
|
||||
self.drag = None;
|
||||
self.targets = None;
|
||||
self.snapped.clear();
|
||||
self.snap = GuideSnap::default();
|
||||
if d.began && on_ruler(cx, d.vertical, ev.pos) {
|
||||
// Dropped on its ruler: the guide goes (a copy is just not made).
|
||||
let mut out = vec![Action::Cancel];
|
||||
@@ -134,35 +240,17 @@ impl GuideEdit {
|
||||
}
|
||||
d.began = true;
|
||||
out.push(Action::Begin(if ev.mods.alt { "Copy Guide" } else { "Move Guide" }.into()));
|
||||
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &[], None));
|
||||
self.snap = GuideSnap::new(cx);
|
||||
}
|
||||
let moved = p - d.start;
|
||||
let (at, snapped) = self.snap(cx, d.vertical, d.from + if d.vertical { moved.x } else { moved.y }, ev.mods.shift);
|
||||
(d.at, d.pointer, self.snapped) = (at, p, snapped);
|
||||
let at = self.snap.snap(cx, d.vertical, d.from + if d.vertical { moved.x } else { moved.y }, ev.mods.shift);
|
||||
(d.at, d.pointer) = (at, p);
|
||||
// The other selected guides follow the pointer: vertical ones across, horizontal ones down.
|
||||
let (dx, dy) = if d.vertical { (at - d.from, moved.y) } else { (moved.x, at - d.from) };
|
||||
out.push(Action::Preview("guide.move".into(), json!({ "dx": dx, "dy": dy, "copy": ev.mods.alt })));
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a guide dragged to `v` goes: with Shift on the nearest ruler tick, else on whole
|
||||
/// pixels, the grid or a smart guide (as a drawn point snaps).
|
||||
fn snap(&self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> (f64, Vec<Overlay>) {
|
||||
if shift {
|
||||
return (cx.unit.snap_to_ruler_tick(v, cx.zoom), vec![]);
|
||||
}
|
||||
if cx.snap_to_pixel {
|
||||
return (v.round(), vec![]);
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return (vectorcraft_geom::snap::snap_to_grid(v, cx.grid_step(), 0.0), vec![]);
|
||||
}
|
||||
match &self.targets {
|
||||
Some(t) => t.snap_guide(vertical, v, cx.tol(5.0)),
|
||||
None => (v, vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer over a guide, or while one is dragged.
|
||||
pub fn cursor(&self, cx: &ToolContext, p: Point) -> Option<Cursor> {
|
||||
if let Some(d) = &self.drag {
|
||||
@@ -174,10 +262,7 @@ impl GuideEdit {
|
||||
/// While a guide is dragged: the smart guide it snapped to and its position by the pointer.
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(d) = self.drag.filter(|d| d.began) else { return vec![] };
|
||||
let mut o = self.snapped.clone();
|
||||
let axis = if d.vertical { "X" } else { "Y" };
|
||||
o.push(Overlay::Measure { p: d.pointer, text: format!("{axis}: {}", cx.len(d.at)) });
|
||||
o
|
||||
self.snap.overlays(cx, d.vertical, d.at, d.pointer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +286,7 @@ mod tests {
|
||||
|
||||
fn guides_doc() -> vectorcraft_doc::Document {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides = vec![Guide { vertical: true, pos: 300.0 }, Guide { vertical: false, pos: 400.0 }];
|
||||
d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)];
|
||||
d
|
||||
}
|
||||
|
||||
@@ -266,6 +351,110 @@ mod tests {
|
||||
assert_eq!(drag_to(&grid, 226.0, Mods::default()).0, 225.0, "the grid's 9 pt subdivisions");
|
||||
}
|
||||
|
||||
/// #451: a guide dragged out of a ruler lands on the art's corners, side midpoints (its
|
||||
/// centre's line) and anchors, and on the artboard's edges and centre, labelled.
|
||||
#[test]
|
||||
fn a_new_guide_snaps_to_the_art_and_the_artboard() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let m = Mods::default();
|
||||
let drag = |vertical: bool, v: f64| {
|
||||
let mut g = NewGuide::new(&c, vertical, None);
|
||||
let at = if vertical { Point::new(v, 50.0) } else { Point::new(50.0, v) };
|
||||
let a = g.pointer(&c, &ev(PointerKind::Drag, at.x, at.y, m), true);
|
||||
let Some(Action::Preview(cmd, p)) = a.last() else { panic!("{a:?}") };
|
||||
assert_eq!((a[0].clone(), cmd.as_str(), &p["vertical"]), (Action::Begin("New Guide".into()), "guide.add", &json!(vertical)));
|
||||
let label = g.overlays(&c).iter().find_map(|o| match o {
|
||||
Overlay::Label { text, .. } => Some(text.clone()),
|
||||
_ => None,
|
||||
});
|
||||
(p["pos"].as_f64().unwrap(), label)
|
||||
};
|
||||
assert_eq!(drag(true, 152.0), (150.0, Some("center".into())), "the line through the top and bottom midpoints");
|
||||
assert_eq!(drag(false, 103.0), (100.0, Some("path".into())), "the top edge, through two corners");
|
||||
assert_eq!(drag(true, 497.0), (500.0, Some("artboard".into())), "the artboard's right edge");
|
||||
assert_eq!(drag(false, 248.0), (250.0, Some("center".into())), "the artboard's centre");
|
||||
assert_eq!(drag(true, 320.0), (320.0, None), "nothing within reach");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_guide_is_made_on_release_over_the_canvas_only() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) };
|
||||
let m = Mods::default();
|
||||
let mut g = NewGuide::new(&c, false, Some(0));
|
||||
let preview = |pos: f64| Action::Preview("guide.add".into(), json!({"vertical": false, "pos": pos, "artboard": 0}));
|
||||
// Off the canvas (still over the ruler): nothing yet.
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![]);
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 30.0, m), true), vec![Action::Begin("New Guide".into()), preview(30.0)]);
|
||||
assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "Y: 30.00 pt"));
|
||||
// The same place again: no new preview.
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 90.0, 30.0, m), true), vec![]);
|
||||
// Back onto the ruler it goes, and comes back over the canvas.
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, -5.0, m), false), vec![Action::Cancel]);
|
||||
assert!(g.overlays(&c).is_empty());
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 60.0, m), true), vec![Action::Begin("New Guide".into()), preview(60.0)]);
|
||||
// Shift: the ruler's ticks.
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Drag, 40.0, 63.0, Mods { shift: true, ..m }), true), vec![preview(65.0)]);
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 40.0, 65.0, m), true), vec![Action::Commit]);
|
||||
// Released off the canvas: none.
|
||||
let mut g = NewGuide::new(&c, true, None);
|
||||
g.pointer(&c, &ev(PointerKind::Drag, 30.0, 40.0, m), true);
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, -5.0, 40.0, m), false), vec![Action::Cancel]);
|
||||
}
|
||||
|
||||
/// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its
|
||||
/// distance), not the edges' midpoints.
|
||||
#[test]
|
||||
fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() {
|
||||
let d = guides_doc();
|
||||
let mut s = Selection::default();
|
||||
s.set_guides([0]);
|
||||
let p = paint();
|
||||
let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) };
|
||||
let moved_to = |c: &ToolContext, x: f64| {
|
||||
let mut g = GuideEdit::default();
|
||||
g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default()));
|
||||
let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, Mods::default())).unwrap();
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
300.0 + v["dx"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(moved_to(&off, 201.5), 200.0);
|
||||
assert_eq!(moved_to(&off, 151.0), 151.0, "the centre is no anchor");
|
||||
assert_eq!(moved_to(&off, 203.0), 203.0, "beyond the 2 px Snap to Point distance");
|
||||
let neither = ToolContext { snap_to_point: false, ..off };
|
||||
assert_eq!(moved_to(&neither, 201.5), 201.5);
|
||||
let mut g = NewGuide::new(&off, true, None);
|
||||
let a = g.pointer(&off, &ev(PointerKind::Drag, 98.5, 20.0, Mods::default()), true);
|
||||
assert_eq!(a.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": 100.0}))));
|
||||
}
|
||||
|
||||
/// #451: an artboard guide runs across its artboard only: it is picked there, and pulls a
|
||||
/// point with Snap to Point there.
|
||||
#[test]
|
||||
fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let id = d.next_artboard_id();
|
||||
d.artboards.push(vectorcraft_doc::Artboard {
|
||||
id,
|
||||
name: "B".into(),
|
||||
rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0),
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
});
|
||||
d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }];
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0));
|
||||
assert_eq!(guide_at(&c, Point::new(602.0 - 4.0, 151.0)), Some(0), "within the tolerance of its end");
|
||||
assert_eq!(guide_at(&c, Point::new(300.0, 151.0)), None, "not across the first artboard");
|
||||
let t = Targets::snap_to_point(&ToolContext { smart_guides: false, ..c }, &[]).unwrap();
|
||||
assert_eq!(t.snap_point(Point::new(700.0, 151.5), 2.0).0, Point::new(700.0, 150.0));
|
||||
assert_eq!(t.snap_point(Point::new(300.0, 151.5), 2.0).0, Point::new(300.0, 151.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_and_alt_drag_copies() {
|
||||
let d = guides_doc();
|
||||
|
||||
@@ -457,7 +457,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_press_on_a_ruler_guide_picks_it_over_the_art() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide { vertical: false, pos: 150.0 });
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 150.0));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
|
||||
@@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
cx.doc
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol)
|
||||
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_passes(g, p, tol))
|
||||
.min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs()))
|
||||
};
|
||||
if let Some(g) = nearest(true, p.x) {
|
||||
@@ -341,7 +341,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_slice_tool_drags_out_a_slice_in_one_undo_step() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides.push(Guide { vertical: true, pos: 403.0 });
|
||||
d.guides.push(Guide::new(true, 403.0));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
|
||||
@@ -174,6 +174,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
ruler_guides(app, ui, full, rect, &xf);
|
||||
}
|
||||
panel_drop(app, ui, &resp, &xf);
|
||||
context_menu(app, &resp, &xf);
|
||||
let painter = ui.painter_at(rect);
|
||||
@@ -319,12 +322,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let picked = LineLook { color: t.selection, ..look };
|
||||
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
|
||||
for (i, g) in doc.guides.iter().enumerate() {
|
||||
let (a, b) = if g.vertical {
|
||||
let x = xf.to_screen(Point::new(g.pos, 0.0)).x;
|
||||
(pos2(x, rect.top()), pos2(x, rect.bottom()))
|
||||
} else {
|
||||
let y = xf.to_screen(Point::new(0.0, g.pos)).y;
|
||||
(pos2(rect.left(), y), pos2(rect.right(), y))
|
||||
let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) });
|
||||
// An artboard guide runs across its artboard, a canvas guide across the window.
|
||||
let (a, b) = match doc.guide_span(g) {
|
||||
Some((from, to)) => (at(from), at(to)),
|
||||
None if g.vertical => {
|
||||
let x = at(0.0).x;
|
||||
(pos2(x, rect.top()), pos2(x, rect.bottom()))
|
||||
}
|
||||
None => {
|
||||
let y = at(0.0).y;
|
||||
(pos2(rect.left(), y), pos2(rect.right(), y))
|
||||
}
|
||||
};
|
||||
if selected.contains(&i) { picked } else { look }.guide(&painter, a, b);
|
||||
}
|
||||
@@ -359,7 +368,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
|
||||
ruler_guides(app, ui, full, rect, &xf, &t);
|
||||
}
|
||||
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
task_bar(app, ui, &xf);
|
||||
@@ -834,34 +842,30 @@ fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
[top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))]
|
||||
}
|
||||
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released: a horizontal
|
||||
/// one from the top ruler, a vertical one from the left ruler; with Shift on the nearest ruler
|
||||
/// tick. Released anywhere else, it makes none.
|
||||
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf, t: &Tokens) {
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
|
||||
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
|
||||
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
|
||||
/// it makes none.
|
||||
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) {
|
||||
let [top, left, _] = ruler_rects(full);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::drag());
|
||||
let Some(p) = resp.interact_pointer_pos().filter(|p| canvas.contains(*p)) else { continue };
|
||||
let d = xf.to_doc(p);
|
||||
let mut pos = if vertical { d.x } else { d.y };
|
||||
if shift {
|
||||
pos = app.session.general_unit().snap_to_ruler_tick(pos, xf.zoom);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
let kind = if resp.drag_stopped() {
|
||||
PointerKind::Up
|
||||
} else if resp.dragged() {
|
||||
PointerKind::Drag
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()));
|
||||
let on_canvas = p.is_some_and(|p| canvas.contains(p));
|
||||
if on_canvas {
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
if let Err(e) = app.run("guide.add", json!({ "vertical": vertical, "pos": pos })) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if resp.dragged() {
|
||||
let at = xf.to_screen(if vertical { Point::new(pos, d.y) } else { Point::new(d.x, pos) });
|
||||
let (a, b) = if vertical {
|
||||
(pos2(at.x, canvas.top()), pos2(at.x, canvas.bottom()))
|
||||
} else {
|
||||
(pos2(canvas.left(), at.y), pos2(canvas.right(), at.y))
|
||||
};
|
||||
LineLook::guides(&app.session.prefs, t).guide(&ui.painter_at(canvas), a, b);
|
||||
}
|
||||
let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 };
|
||||
if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1750,6 +1754,54 @@ mod tests {
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing");
|
||||
}
|
||||
|
||||
/// #451: a guide dragged out of a ruler lands on the art's side midpoints (halving an
|
||||
/// artboard-sized rectangle) and on the artboard's edges; an artboard guide is drawn across
|
||||
/// its artboard only.
|
||||
#[test]
|
||||
fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||||
frame(app, &ctx, vec![button(from, true)]);
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
frame(app, &ctx, vec![button(to, false)]);
|
||||
};
|
||||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||||
// 3 px off the middle of the top and bottom sides: onto the line through them.
|
||||
let mid = xf.to_screen(Point::new(200.0, 150.0));
|
||||
drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0));
|
||||
// 3 px inside the artboard's bottom edge: onto it.
|
||||
let bottom = xf.to_screen(Point::new(100.0, 300.0));
|
||||
drag(&mut app, pos2(bottom.x, rect.top() - RULER / 2.0), bottom - vec2(0.0, 3.0));
|
||||
assert_eq!(guides(&app), [(true, 200.0), (false, 300.0)]);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "New Guide");
|
||||
// An artboard guide runs from the artboard's left edge to its right edge.
|
||||
app.session.execute("guide.add", &json!({"vertical": false, "pos": 75, "artboard": 0})).unwrap();
|
||||
let s = shapes(&mut app, &ctx);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let (a, b) = (xf.to_screen(Point::new(0.0, 75.0)), xf.to_screen(Point::new(400.0, 75.0)));
|
||||
let at = |p: Pos2, q: Pos2| (p - q).length() < 0.01;
|
||||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } if at(points[0], a) && at(points[1], b))), "across the artboard");
|
||||
// A canvas guide crosses the whole window.
|
||||
let y = xf.to_screen(Point::new(0.0, 300.0)).y;
|
||||
let window = app.canvas_rect.unwrap();
|
||||
assert!(
|
||||
s.iter().any(
|
||||
|s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// #414: guides dragged out of the rulers are picked and dragged with the Selection tool
|
||||
/// (highlighted while selected), go with Delete or Backspace, and dragged back onto their
|
||||
/// ruler.
|
||||
|
||||
+12
-4
@@ -566,8 +566,12 @@ repeated names, and ids the document doesn't have are dropped.
|
||||
|
||||
## Ruler guides
|
||||
|
||||
Ruler guides are numbered in the order they were made. `guide.add {vertical, pos}` makes one (the x of a vertical
|
||||
guide, the y of a horizontal one, in points) → `{index}`; `guide.list` → `[{index, vertical, pos, selected}…]`.
|
||||
Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a
|
||||
vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an
|
||||
artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in
|
||||
`artboard.setProps`, `artboard.rearrange`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is
|
||||
deleted with it (`artboard.delete`); without, a canvas guide runs across the whole canvas. `guide.list` →
|
||||
`[{index, vertical, pos, selected, artboard?}…]` (`artboard` only for artboard guides).
|
||||
`guide.select {indexes: [index…], toggle?}` selects guides on their own (the art is deselected; `toggle` adds or
|
||||
removes them) → `{selected}`. `guide.move {index, pos}` puts one guide somewhere, `guide.move {dx?, dy?, copy?}`
|
||||
moves the selected ones (vertical guides by `dx`, horizontal ones by `dy`; `copy` leaves them and selects the
|
||||
@@ -578,11 +582,15 @@ View › Guides › Lock Guides (`view.guides.lock`) deselects them and refuses
|
||||
The Selection, Direct Selection and Group Selection tools pick a guide within the selection tolerance, over the art
|
||||
(an anchor on it comes first with Direct Selection), and drag the selected guides: `mods.alt` copies them,
|
||||
`mods.shift` snaps the dragged guide to the ruler's ticks, and otherwise it snaps to whole pixels, the grid or, with
|
||||
Smart Guides, the art's edges, centres and anchors. In the desktop app a guide dropped off the canvas onto its
|
||||
ruler is deleted, and hidden guides (View › Guides › Hide Guides) can't be picked.
|
||||
Smart Guides, the edges, centres (so the sides' midpoints) and anchors of the art and of the artboards; with Smart
|
||||
Guides off, View › Snap to Point pulls it onto anchors. In the desktop app a guide dragged out of a ruler snaps the
|
||||
same way and is made where it is released over the canvas (with the Artboard tool, as an artboard guide of the
|
||||
active artboard), a guide dropped off the canvas onto its ruler is deleted, and hidden guides (View › Guides › Hide
|
||||
Guides) can't be picked. An artboard guide is picked, drawn and snapped to across its artboard only.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"guide.add","params":{"vertical":true,"pos":100}}}
|
||||
{"name":"run_command","arguments":{"command":"guide.add","params":{"vertical":false,"pos":200,"artboard":0}}}
|
||||
{"name":"pointer_gesture","arguments":{"tool":"selection","events":[{"kind":"down","x":100,"y":50},{"kind":"drag","x":140,"y":50},{"kind":"up","x":140,"y":50}]}}
|
||||
{"name":"run_command","arguments":{"command":"guide.list","params":{}}}
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user