commit
85dcb28e41
20
ROADMAP.md
20
ROADMAP.md
File diff suppressed because one or more lines are too long
|
|
@ -78,7 +78,8 @@ fn add_open_urls_method() {
|
|||
|
||||
/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit.
|
||||
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
|
||||
let paths = urls.iter().filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
||||
// Only file URLs: macOS 27 turns an https URL's path into a file path too (#433).
|
||||
let paths = urls.iter().filter(|u| u.isFileURL()).filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
|
||||
if let Some(ctx) = UI.get() {
|
||||
ctx.request_repaint();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
//! Live Corners on any path: the corners of its uncut outline (a live rectangle's or polygon's, a
|
||||
//! live path's, or a plain path's own), each one's radius and kind, and the anchors of the path as
|
||||
//! drawn that belong to each. [`set_corners`] edits them.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
|
||||
use crate::node::{LiveShape, Node, NodeKind};
|
||||
use crate::selection::AnchorRef;
|
||||
|
||||
/// The Live Corners of a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiveCorners<'a> {
|
||||
/// The path with its corners uncut, in the corners' own space: a live rectangle's (its size
|
||||
/// and radii in document units, see [`LiveShape::folded`]), else the document's.
|
||||
pub base: Cow<'a, PathData>,
|
||||
/// Maps `base` into the document.
|
||||
pub xf: Affine,
|
||||
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
|
||||
pub corners: Vec<Corner>,
|
||||
radii: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
sources: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> LiveCorners<'a> {
|
||||
/// The Live Corners of `path` and its live shape; `None` for shapes without corners (ellipses,
|
||||
/// lines).
|
||||
pub fn new(path: &'a PathData, live: Option<&'a LiveShape>) -> Option<Self> {
|
||||
let (base, xf, radii, kinds) = match live {
|
||||
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![]),
|
||||
Some(l @ LiveShape::Rectangle { .. }) => {
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, xf } = l.folded() else { return None };
|
||||
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec())
|
||||
}
|
||||
Some(l @ LiveShape::Polygon { radii, kinds, .. }) => (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone()),
|
||||
Some(LiveShape::Path { base, radii, kinds }) => (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone()),
|
||||
Some(LiveShape::Ellipse { .. } | LiveShape::Line { .. }) => return None,
|
||||
};
|
||||
let sources = if radii.iter().any(|r| *r > 0.0) {
|
||||
cut_corners(&base, &radii, &kinds).1
|
||||
} else {
|
||||
let mut first = 0;
|
||||
base.subpaths
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
first += sp.anchors.len();
|
||||
(first - sp.anchors.len()..first).collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
|
||||
}
|
||||
|
||||
/// The Live Corners of a path object.
|
||||
pub fn of(node: &'a Node) -> Option<Self> {
|
||||
let NodeKind::Path { path, live, .. } = &node.kind else { return None };
|
||||
Self::new(path, live.as_ref())
|
||||
}
|
||||
|
||||
/// The corner at anchor `index` of `base`.
|
||||
pub fn corner(&self, index: usize) -> Option<&Corner> {
|
||||
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i))
|
||||
}
|
||||
|
||||
/// The radius corner `index` is set to, in document units (drawn no larger than fits).
|
||||
pub fn radius(&self, index: usize) -> f64 {
|
||||
self.radii.get(index).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The kind corner `index` is cut with.
|
||||
pub fn kind(&self, index: usize) -> CornerKind {
|
||||
self.kinds.get(index).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every corner.
|
||||
pub fn all(&self) -> BTreeSet<usize> {
|
||||
self.corners.iter().map(|c| c.index).collect()
|
||||
}
|
||||
|
||||
/// The corners holding one of `anchors` (of the path as drawn).
|
||||
pub fn corners_of(&self, anchors: &BTreeSet<AnchorRef>) -> BTreeSet<usize> {
|
||||
anchors.iter().filter_map(|(si, ai)| self.sources.get(*si)?.get(*ai).copied()).filter(|k| self.corner(*k).is_some()).collect()
|
||||
}
|
||||
|
||||
/// The corners Live Corners edit for a selection: those holding a selected anchor when the
|
||||
/// path is partly selected (Direct Selection), else every corner.
|
||||
pub fn picked(&self, partial: Option<&BTreeSet<AnchorRef>>) -> BTreeSet<usize> {
|
||||
let picked = partial.map(|a| self.corners_of(a)).unwrap_or_default();
|
||||
if picked.is_empty() { self.all() } else { picked }
|
||||
}
|
||||
|
||||
/// The anchors of the path as drawn that belong to `corners` (to keep them selected as the
|
||||
/// path changes).
|
||||
pub fn anchors_of(&self, corners: &BTreeSet<usize>) -> BTreeSet<AnchorRef> {
|
||||
let mut out = BTreeSet::new();
|
||||
for (si, from) in self.sources.iter().enumerate() {
|
||||
out.extend(from.iter().enumerate().filter(|(_, k)| corners.contains(k)).map(|(ai, _)| (si, ai)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
pub fn sources(&self) -> &[Vec<usize>] {
|
||||
&self.sources
|
||||
}
|
||||
|
||||
/// The radius (document units) and kind `corners` share (each `None` when they differ, or
|
||||
/// for no corners).
|
||||
pub fn style(&self, corners: &BTreeSet<usize>) -> (Option<f64>, Option<CornerKind>) {
|
||||
fn shared<T: Copy>(mut it: impl Iterator<Item = T>, same: impl Fn(T, T) -> bool) -> Option<T> {
|
||||
let first = it.next()?;
|
||||
it.all(|x| same(x, first)).then_some(first)
|
||||
}
|
||||
let radius = shared(corners.iter().map(|k| self.radius(*k)), |a, b| (a - b).abs() < 1e-9);
|
||||
(radius, shared(corners.iter().map(|k| self.kind(*k)), |a, b| a == b))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `radius` (document units; negative: 0) and/or `kind` on the `corners` (anchor indices of
|
||||
/// the uncut outline, see [`LiveCorners`]) of a path and its live shape, then cut the path again.
|
||||
/// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is a
|
||||
/// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they
|
||||
/// are.
|
||||
pub fn set_corners(path: &mut PathData, live: &mut Option<LiveShape>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
|
||||
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
|
||||
for k in corners {
|
||||
if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) {
|
||||
*slot = r.max(0.0);
|
||||
}
|
||||
if let (Some(kind), Some(slot)) = (kind, kinds.get_mut(*k)) {
|
||||
*slot = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One radius and kind per corner of an outline of `n` anchors while editing; none saved
|
||||
/// when all are sharp or round.
|
||||
fn edit_vec(
|
||||
n: usize,
|
||||
radii: &mut Vec<f64>,
|
||||
kinds: &mut Vec<CornerKind>,
|
||||
corners: &BTreeSet<usize>,
|
||||
radius: Option<f64>,
|
||||
kind: Option<CornerKind>,
|
||||
) {
|
||||
radii.resize(n, 0.0);
|
||||
kinds.resize(n, CornerKind::Round);
|
||||
edit(radii, kinds, corners, radius, kind);
|
||||
if radii.iter().all(|r| *r <= 0.0) {
|
||||
radii.clear();
|
||||
}
|
||||
if kinds.iter().all(|k| *k == CornerKind::Round) {
|
||||
kinds.clear();
|
||||
}
|
||||
}
|
||||
let l = live.get_or_insert_with(|| LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] });
|
||||
// Radii are document lengths and corners circular, also on a rectangle from a file that kept
|
||||
// an uneven scale in its transform (#442).
|
||||
l.fold_scale();
|
||||
match l {
|
||||
LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind),
|
||||
LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind),
|
||||
LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind),
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
|
||||
}
|
||||
match live {
|
||||
Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => {
|
||||
*path = std::mem::take(base);
|
||||
*live = None;
|
||||
}
|
||||
Some(l) => *path = l.to_path(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::{Point, SubPath};
|
||||
|
||||
fn set(v: &[usize]) -> BTreeSet<usize> {
|
||||
v.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn anchors(v: &[usize]) -> BTreeSet<AnchorRef> {
|
||||
v.iter().map(|ai| (0, *ai)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_corners_map_to_their_anchors() {
|
||||
let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
|
||||
let path = l.to_path();
|
||||
let c = LiveCorners::new(&path, Some(&l)).unwrap();
|
||||
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
|
||||
assert_eq!(c.sources(), [vec![0, 1, 1, 2, 3]]);
|
||||
assert_eq!(path.anchor_count(), 5);
|
||||
assert_eq!(c.corners_of(&anchors(&[2, 4])), set(&[1, 3]));
|
||||
assert!(c.corners_of(&anchors(&[9])).is_empty(), "out of range");
|
||||
assert_eq!(c.anchors_of(&set(&[1, 3])), anchors(&[1, 2, 4]));
|
||||
// Partly selected: the corners of the selected anchors; wholly (or no anchor): all four.
|
||||
assert_eq!(c.picked(Some(&anchors(&[0]))), set(&[0]));
|
||||
assert_eq!(c.picked(None), set(&[0, 1, 2, 3]));
|
||||
assert_eq!(c.picked(Some(&anchors(&[]))), set(&[0, 1, 2, 3]));
|
||||
assert_eq!(c.style(&set(&[1])), (Some(10.0), Some(CornerKind::Round)));
|
||||
assert_eq!(c.style(&set(&[0, 1])), (None, Some(CornerKind::Round)));
|
||||
assert_eq!(c.style(&set(&[0, 2, 3])), (Some(0.0), Some(CornerKind::Round)));
|
||||
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
|
||||
assert!(LiveCorners::new(&e.to_path(), Some(&e)).is_none());
|
||||
}
|
||||
|
||||
/// #511: a plain path (a star) rounds its corners and keeps them editable; with none cut it
|
||||
/// is plain again.
|
||||
#[test]
|
||||
fn a_plain_path_keeps_its_cut_corners_live() {
|
||||
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
|
||||
let (mut path, mut live) = (star.clone(), None);
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None);
|
||||
let Some(LiveShape::Path { base, radii, kinds }) = &live else { panic!("live corners: {live:?}") };
|
||||
assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0));
|
||||
assert_eq!(path.anchor_count(), 12, "two corners cut");
|
||||
let c = LiveCorners::new(&path, live.as_ref()).unwrap();
|
||||
assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round)));
|
||||
assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path");
|
||||
// Kinds alone keep it live; squaring every corner makes it the plain star again.
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer));
|
||||
assert!(matches!(&live, Some(LiveShape::Path { radii, .. }) if radii.is_empty()));
|
||||
set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round));
|
||||
assert_eq!((path, live), (star, None));
|
||||
}
|
||||
|
||||
/// A polygon's corners are cut in the document and survive a change of sides when shared.
|
||||
#[test]
|
||||
fn polygon_corners_follow_their_sides() {
|
||||
let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] };
|
||||
let (mut path, mut live) = (l.to_path(), Some(l.clone()));
|
||||
set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None);
|
||||
assert_eq!(path.anchor_count(), 12);
|
||||
l = live.clone().unwrap();
|
||||
l.set_sides(8);
|
||||
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8]));
|
||||
assert_eq!(l.to_path().anchor_count(), 16);
|
||||
// Mixed radii: the new corners are sharp.
|
||||
set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None);
|
||||
let mut l = live.unwrap();
|
||||
l.set_sides(7);
|
||||
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0]));
|
||||
// An uneven scale keeps the cuts circular: the radius scales by the mean scale.
|
||||
let mut p = l.clone();
|
||||
assert!(p.transform(Affine::scale_non_uniform(4.0, 1.0)));
|
||||
assert!(matches!(&p, LiveShape::Polygon { radii, .. } if (radii[0] - 16.0).abs() < 1e-9));
|
||||
assert!(!p.transform(Affine::translate((5.0, 0.0))), "a move needs no new cuts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_paths_and_curves_keep_their_ends_and_smooth_anchors() {
|
||||
let pen = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false));
|
||||
let c = LiveCorners::new(&pen, None).unwrap();
|
||||
assert_eq!(c.all(), set(&[1]));
|
||||
assert_eq!(c.picked(Some(&anchors(&[0]))), set(&[1]), "an end is no corner: every corner");
|
||||
let (mut path, mut live) = (pen.clone(), None);
|
||||
set_corners(&mut path, &mut live, &c.all(), Some(10.0), None);
|
||||
assert_eq!(path.anchor_count(), 4);
|
||||
assert_eq!(path.subpaths[0].anchors[0].p, Point::new(0.0, 0.0));
|
||||
assert_eq!(path.subpaths[0].anchors[3].p, Point::new(100.0, 80.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ pub mod assets;
|
|||
pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
pub mod inks;
|
||||
|
|
@ -58,6 +59,7 @@ pub use appearance::{
|
|||
StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use graph::{GraphKind, GraphSpec};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
//! Document nodes.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
use crate::pattern::RepeatSpec;
|
||||
use crate::selection::AnchorRef;
|
||||
use crate::text::TextObject;
|
||||
|
||||
/// Stable per-document object id. Never reused.
|
||||
|
|
@ -104,11 +103,30 @@ pub enum LiveShape {
|
|||
radius: f64,
|
||||
sides: u32,
|
||||
xf: Affine,
|
||||
/// Corner radii in document units, by vertex from the first one clockwise (none: sharp).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
radii: Vec<f64>,
|
||||
/// Corner kinds, in the same order (none: round).
|
||||
#[serde(default, skip_serializing_if = "all_round")]
|
||||
kinds: Vec<CornerKind>,
|
||||
},
|
||||
Line {
|
||||
a: Point,
|
||||
b: Point,
|
||||
},
|
||||
/// Any other path whose corners Live Corners cut (a star, a pen path): it keeps its uncut
|
||||
/// outline so the corners stay editable. Without a cut corner it is a plain path again.
|
||||
Path {
|
||||
/// The path with its corners uncut, in the document.
|
||||
base: PathData,
|
||||
/// Corner radii in document units, by anchor index of `base` (counting every subpath's
|
||||
/// anchors in order; missing: sharp).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
radii: Vec<f64>,
|
||||
/// Corner kinds, in the same order (missing: round).
|
||||
#[serde(default, skip_serializing_if = "all_round")]
|
||||
kinds: Vec<CornerKind>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LiveShape {
|
||||
|
|
@ -119,6 +137,7 @@ impl LiveShape {
|
|||
LiveShape::Ellipse { .. } => "Ellipse",
|
||||
LiveShape::Polygon { .. } => "Polygon",
|
||||
LiveShape::Line { .. } => "Line",
|
||||
LiveShape::Path { .. } => "Path",
|
||||
}
|
||||
}
|
||||
/// Regenerate the path.
|
||||
|
|
@ -128,10 +147,33 @@ impl LiveShape {
|
|||
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
|
||||
}
|
||||
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
|
||||
LiveShape::Polygon { radius, sides, xf } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
|
||||
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
|
||||
}
|
||||
}
|
||||
/// A polygon's outline with its corners uncut, in the document (empty for other shapes).
|
||||
pub fn polygon_outline(&self) -> PathData {
|
||||
match self {
|
||||
LiveShape::Polygon { radius, sides, xf, .. } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
|
||||
_ => PathData::default(),
|
||||
}
|
||||
}
|
||||
/// Give a polygon `n` sides (3–1000). Its corners keep the radius and kind they shared; else
|
||||
/// the corners past the old last one are sharp.
|
||||
pub fn set_sides(&mut self, n: u64) {
|
||||
let LiveShape::Polygon { sides, radii, kinds, .. } = self else { return };
|
||||
*sides = n.clamp(3, 1000) as u32;
|
||||
fn spread<T: Copy + PartialEq + Default>(v: &mut Vec<T>, n: usize) {
|
||||
match v.first().copied() {
|
||||
Some(x) if v.iter().all(|y| *y == x) => *v = vec![x; n],
|
||||
Some(_) => v.resize(n, T::default()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
spread(radii, *sides as usize);
|
||||
spread(kinds, *sides as usize);
|
||||
}
|
||||
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
|
||||
/// the caller must regenerate the path with [`LiveShape::to_path`].
|
||||
///
|
||||
|
|
@ -140,19 +182,27 @@ impl LiveShape {
|
|||
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
|
||||
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
|
||||
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed).
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed). A polygon's or a
|
||||
/// path's corner radii are document lengths: they scale by the mean scale and the corners are
|
||||
/// cut again, circular whatever `a` does.
|
||||
pub fn transform(&mut self, a: Affine) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
|
||||
!moves_only && self.fold_scale()
|
||||
!moves_only(a) && self.fold_scale()
|
||||
}
|
||||
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||||
LiveShape::Ellipse { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
false
|
||||
}
|
||||
LiveShape::Polygon { xf, radii, .. } => {
|
||||
*xf = a * *xf;
|
||||
scale_radii(radii, a)
|
||||
}
|
||||
LiveShape::Path { base, radii, .. } => {
|
||||
base.transform(a);
|
||||
scale_radii(radii, a)
|
||||
}
|
||||
LiveShape::Line { a: p, b } => {
|
||||
*p = a * *p;
|
||||
*b = a * *b;
|
||||
|
|
@ -192,52 +242,16 @@ impl LiveShape {
|
|||
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
|
||||
/// keep their size (Scale Corners off). False when nothing changed.
|
||||
pub fn keep_corners(&mut self, k: f64) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) && k > 1e-12 => {
|
||||
radii.iter_mut().for_each(|r| *r /= k);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
let radii: &mut [f64] = match self {
|
||||
LiveShape::Rectangle { radii, .. } => radii,
|
||||
LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii,
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false,
|
||||
};
|
||||
if !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/// The corner (radii order) of each anchor of a rectangle's path; empty for other shapes.
|
||||
pub fn anchor_corners(&self) -> Vec<usize> {
|
||||
match self {
|
||||
LiveShape::Rectangle { w, h, radii, .. } => shapes::rectangle_anchor_corners(Rect::new(0.0, 0.0, *w, *h), *radii),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
/// The corners of a rectangle that hold one of `anchors` (of its only subpath).
|
||||
pub fn corners_of(&self, anchors: &BTreeSet<AnchorRef>) -> [bool; 4] {
|
||||
let map = self.anchor_corners();
|
||||
let mut out = [false; 4];
|
||||
for k in anchors.iter().filter(|(si, _)| *si == 0).filter_map(|(_, ai)| map.get(*ai)) {
|
||||
if let Some(c) = out.get_mut(*k) {
|
||||
*c = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The corners Live Corners edit for a selection: those holding a selected anchor when the
|
||||
/// shape is partly selected (Direct Selection), else all four.
|
||||
pub fn picked_corners(&self, partial: Option<&BTreeSet<AnchorRef>>) -> [bool; 4] {
|
||||
let picked = partial.map_or([true; 4], |a| self.corners_of(a));
|
||||
if picked.contains(&true) { picked } else { [true; 4] }
|
||||
}
|
||||
/// The anchors of a rectangle's `corners` (to keep them selected as its path changes).
|
||||
pub fn corner_anchors(&self, corners: [bool; 4]) -> BTreeSet<AnchorRef> {
|
||||
self.anchor_corners().into_iter().enumerate().filter(|(_, k)| corners.get(*k) == Some(&true)).map(|(ai, _)| (0, ai)).collect()
|
||||
}
|
||||
/// The radius (in document units, see [`LiveShape::folded`]) and kind the `corners` of a
|
||||
/// rectangle share (each `None` when they differ).
|
||||
pub fn corner_style(&self, corners: [bool; 4]) -> (Option<f64>, Option<CornerKind>) {
|
||||
let LiveShape::Rectangle { radii, kinds, .. } = self.folded() else { return (None, None) };
|
||||
fn shared<T: Copy>(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option<T> {
|
||||
let mut it = v.into_iter().zip(corners).filter(|(_, on)| *on).map(|(x, _)| x);
|
||||
let first = it.next()?;
|
||||
it.all(|x| same(x, first)).then_some(first)
|
||||
}
|
||||
(shared(radii, corners, |a, b| (a - b).abs() < 1e-9), shared(kinds, corners, |a, b| a == b))
|
||||
radii.iter_mut().for_each(|r| *r /= k);
|
||||
true
|
||||
}
|
||||
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
|
|
@ -247,14 +261,33 @@ impl LiveShape {
|
|||
c[1].atan2(c[0]).to_degrees()
|
||||
}
|
||||
LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(),
|
||||
LiveShape::Path { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn all_round(kinds: &[CornerKind; 4]) -> bool {
|
||||
fn all_round(kinds: &[CornerKind]) -> bool {
|
||||
kinds.iter().all(|k| *k == CornerKind::Round)
|
||||
}
|
||||
|
||||
/// Whether `a` only moves (no rotation, scale, shear or reflection).
|
||||
fn moves_only(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
(a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12
|
||||
}
|
||||
|
||||
/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True
|
||||
/// when the path must be cut again: some corner is cut and `a` does more than move it (an
|
||||
/// uneven scale would otherwise make the cuts elliptical).
|
||||
fn scale_radii(radii: &mut [f64], a: Affine) -> bool {
|
||||
if moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let k = a.determinant().abs().sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
true
|
||||
}
|
||||
|
||||
/// What a transform scales besides geometry, by its mean scale (the square root of its
|
||||
/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke
|
||||
/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects
|
||||
|
|
@ -1214,7 +1247,7 @@ impl Node {
|
|||
|
||||
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
|
||||
pub fn shows_center_by_default(&self) -> bool {
|
||||
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. }))
|
||||
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. } | LiveShape::Path { .. }))
|
||||
}
|
||||
|
||||
/// Change this object's attributes with `f`; all-default attributes are dropped.
|
||||
|
|
@ -1276,23 +1309,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_corners_map_to_their_anchors() {
|
||||
fn corner_kinds_save_only_when_some_corner_isnt_round() {
|
||||
let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
|
||||
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
|
||||
assert_eq!(l.anchor_corners(), [0, 1, 1, 2, 3]);
|
||||
assert_eq!(l.to_path().anchor_count(), 5);
|
||||
let set = |v: &[usize]| v.iter().map(|ai| (0, *ai)).collect::<BTreeSet<AnchorRef>>();
|
||||
assert_eq!(l.corners_of(&set(&[2, 4])), [false, true, false, true]);
|
||||
assert_eq!(l.corners_of(&set(&[9])), [false; 4], "out of range");
|
||||
assert_eq!(l.corner_anchors([false, true, false, true]), set(&[1, 2, 4]));
|
||||
// Partly selected: the corners of the selected anchors; wholly (or no anchor): all four.
|
||||
assert_eq!(l.picked_corners(Some(&set(&[0]))), [true, false, false, false]);
|
||||
assert_eq!(l.picked_corners(None), [true; 4]);
|
||||
assert_eq!(l.picked_corners(Some(&set(&[]))), [true; 4]);
|
||||
assert_eq!(l.corner_style([false, true, false, false]), (Some(10.0), Some(CornerKind::Round)));
|
||||
assert_eq!(l.corner_style([true, true, false, false]), (None, Some(CornerKind::Round)));
|
||||
assert_eq!(l.corner_style([true, false, true, true]), (Some(0.0), Some(CornerKind::Round)));
|
||||
// Corner kinds save only when some corner isn't round, and older files read as round.
|
||||
// Older files read as round.
|
||||
let json = serde_json::to_value(&l).unwrap();
|
||||
assert!(json.get("kinds").is_none(), "{json}");
|
||||
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
|
||||
|
|
@ -1302,7 +1321,16 @@ mod tests {
|
|||
let json = serde_json::to_value(&l).unwrap();
|
||||
assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"]));
|
||||
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
|
||||
assert!(LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }.anchor_corners().is_empty());
|
||||
// Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips.
|
||||
let p: LiveShape =
|
||||
serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap();
|
||||
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![] });
|
||||
let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0);
|
||||
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![] };
|
||||
let json = serde_json::to_value(&path).unwrap();
|
||||
assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None));
|
||||
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), path);
|
||||
assert_eq!(path.label(), "Path");
|
||||
}
|
||||
|
||||
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
|
||||
|
|
@ -1427,7 +1455,8 @@ mod tests {
|
|||
assert!(near_xf(*xf, Affine::translate((5.0, 5.0))));
|
||||
let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap());
|
||||
assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}");
|
||||
assert_eq!(live.corner_style([true; 4]).0, Some(40.0), "the radius in document units");
|
||||
let corners = crate::LiveCorners::of(&n).unwrap();
|
||||
assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units");
|
||||
// Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are.
|
||||
let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0);
|
||||
let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText)} → {id}",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
|
|
@ -188,7 +188,8 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let r = f64_req(p, "radius", "shape.polygon")?.abs();
|
||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
|
||||
let rot = f64_or(p, "rotation", 0.0);
|
||||
let live = LiveShape::Polygon { radius: r, sides, xf: Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()) };
|
||||
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
|
||||
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![] };
|
||||
add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None)
|
||||
}
|
||||
|
||||
|
|
@ -409,6 +410,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
||||
t.para = new_type_para();
|
||||
new_type_alignment(s, p, "text.create", &mut t)?;
|
||||
if let Some(a) = p.get("area") {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
|
|
@ -435,6 +437,40 @@ pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
|
|||
}
|
||||
}
|
||||
|
||||
/// The leading model and character alignment new type starts with (`t`'s paragraph and runs):
|
||||
/// `leadingModel` and `charAlign` from `p`, else em box top to top and em box centre while the
|
||||
/// interface is in Japanese (#432), else the Roman baseline for both. Noted in the journal, so a
|
||||
/// replay sets the same whatever the interface.
|
||||
pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut TextObject) -> Result<()> {
|
||||
use vectorcraft_doc::{CharAlign, LeadingModel};
|
||||
let japanese = s.japanese_interface();
|
||||
let leading = match p.get("leadingModel").filter(|v| !v.is_null()) {
|
||||
Some(v) => match v.as_str() {
|
||||
Some("romanBaseline") => LeadingModel::RomanBaseline,
|
||||
Some("emBoxTop") => LeadingModel::EmBoxTop,
|
||||
_ => return Err(bad(cmd, "`leadingModel` must be \"romanBaseline\" or \"emBoxTop\"")),
|
||||
},
|
||||
None => {
|
||||
let l = if japanese { LeadingModel::EmBoxTop } else { LeadingModel::RomanBaseline };
|
||||
s.note_journal("leadingModel", json!(l));
|
||||
l
|
||||
}
|
||||
};
|
||||
let align = match super::textedit::char_align_param(p, cmd)? {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let a = if japanese { CharAlign::EmBoxCenter } else { CharAlign::RomanBaseline };
|
||||
s.note_journal("charAlign", json!(a));
|
||||
a
|
||||
}
|
||||
};
|
||||
t.para.leading_model = leading;
|
||||
for r in &mut t.runs {
|
||||
r.style.char_align = align;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
//! Object menu: transforms, arrange, group, lock/hide, compound paths, clipping masks, isolation,
|
||||
//! align & distribute, object properties.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_doc::corners::set_corners;
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
|
||||
|
|
@ -194,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [0..3…], sides?: n} (Live Corners: radius and kind set a rectangle's corners: `corners` (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), else the corners with a Direct-Selected anchor, else all four; sides: a polygon's)",
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
|
|
@ -1006,21 +1008,12 @@ fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
/// The `corners` of `object.setLiveShape`: a mask of corner indices 0–3.
|
||||
fn corners_param(p: &Value) -> Result<Option<[bool; 4]>> {
|
||||
/// The `corners` of `object.setLiveShape`: anchor indices of a path's uncut outline.
|
||||
fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
|
||||
let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let err = || {
|
||||
bad(
|
||||
"object.setLiveShape",
|
||||
format!("`corners` must list corner indices 0–3 (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), not {v}"),
|
||||
)
|
||||
};
|
||||
let mut mask = [false; 4];
|
||||
for k in v.as_array().ok_or_else(err)? {
|
||||
let slot = k.as_u64().and_then(|k| mask.get_mut(usize::try_from(k).ok()?)).ok_or_else(err)?;
|
||||
*slot = true;
|
||||
}
|
||||
Ok(Some(mask))
|
||||
let err = || bad("object.setLiveShape", format!("`corners` must list anchor indices (0, 1, 2…), not {v}"));
|
||||
let list = v.as_array().ok_or_else(err)?;
|
||||
list.iter().map(|k| k.as_u64().and_then(|k| usize::try_from(k).ok()).ok_or_else(err)).collect::<Result<_>>().map(Some)
|
||||
}
|
||||
|
||||
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -1035,39 +1028,37 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})
|
||||
.transpose()?;
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
let sides = p.get("sides").and_then(Value::as_u64);
|
||||
s.edit("Live Shape", |d, sel| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
// Radii are document lengths and corners circular, also on a rectangle from a file
|
||||
// that kept an uneven scale in its transform (#442).
|
||||
live.fold_scale();
|
||||
let partial = sel.anchors.get_mut(id);
|
||||
let picked = corners.unwrap_or_else(|| live.picked_corners(partial.as_deref()));
|
||||
let (layout, selected) = (live.anchor_corners(), partial.as_deref().map(|a| live.corners_of(a)));
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { radii, kinds, .. } => {
|
||||
for ((_, r), k) in picked.iter().zip(radii.iter_mut()).zip(kinds.iter_mut()).filter(|((on, _), _)| **on) {
|
||||
if let Some(radius) = radius {
|
||||
*r = radius.max(0.0);
|
||||
}
|
||||
if let Some(kind) = kind {
|
||||
*k = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
|
||||
*sides = n.clamp(3, 1000) as u32;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
if let (Some(l @ LiveShape::Polygon { .. }), Some(n)) = (live.as_mut(), sides) {
|
||||
l.set_sides(n);
|
||||
*path = l.to_path();
|
||||
}
|
||||
*path = live.to_path();
|
||||
if radius.is_none() && kind.is_none() {
|
||||
continue;
|
||||
}
|
||||
let partial = sel.anchors.get_mut(id);
|
||||
// The corners to edit, the Direct-Selected ones and the anchor layout before the edit.
|
||||
let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue };
|
||||
let picked = match &corners {
|
||||
Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => {
|
||||
let n = c.base.anchor_count();
|
||||
let past: Vec<_> = k.range(n..).collect();
|
||||
return Err(bad("object.setLiveShape", format!("object {id} has anchors 0–{} for `corners`, not {past:?}", n.saturating_sub(1))));
|
||||
}
|
||||
Some(k) => k.clone(),
|
||||
None => c.picked(partial.as_deref()),
|
||||
};
|
||||
let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec());
|
||||
set_corners(path, live, &picked, radius, kind);
|
||||
// Direct-Selected corners stay selected as they gain or lose anchors.
|
||||
if let (Some(anchors), Some(selected)) = (partial, selected)
|
||||
&& live.anchor_corners() != layout
|
||||
&& let Some(c) = LiveCorners::new(path, live.as_ref())
|
||||
&& c.sources() != layout
|
||||
{
|
||||
*anchors = live.corner_anchors(selected);
|
||||
*anchors = c.anchors_of(&selected);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, placeholder?: bool (placeholder text instead, as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
|
|
@ -471,6 +471,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
super::create::new_type_alignment(s, p, C, &mut t)?;
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -261,6 +261,26 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, V
|
|||
}
|
||||
}
|
||||
|
||||
/// The attributes of a new style made from nothing (no `attrs`, no text selected): while the
|
||||
/// interface is in Japanese, new type's em box top-to-top leading (paragraph styles) and em box
|
||||
/// centre alignment (character styles); else none (the defaults).
|
||||
fn new_style_attrs(s: &Session, kind: Kind) -> Map<String, Value> {
|
||||
if !s.japanese_interface() {
|
||||
return Map::new();
|
||||
}
|
||||
let (set, default) = match kind {
|
||||
Kind::Char => {
|
||||
let st = CharStyle { char_align: vectorcraft_doc::CharAlign::EmBoxCenter, ..CharStyle::default() };
|
||||
(to_attrs(&st), to_attrs(&CharStyle::default()))
|
||||
}
|
||||
Kind::Para => {
|
||||
let st = ParaStyle { leading_model: vectorcraft_doc::LeadingModel::EmBoxTop, ..ParaStyle::default() };
|
||||
(to_attrs(&st), to_attrs(&ParaStyle::default()))
|
||||
}
|
||||
};
|
||||
set.into_iter().filter(|(k, v)| default.get(k) != Some(v)).collect()
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn list(s: &mut Session, kind: Kind) -> Result<Value> {
|
||||
|
|
@ -275,7 +295,14 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
|||
let c = kind.cmd("new");
|
||||
let attrs = match attrs_param(p, kind, "new")? {
|
||||
Some(a) => a,
|
||||
None => selection_attrs(s, p, kind)?.unwrap_or_default(),
|
||||
None => match selection_attrs(s, p, kind)? {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let a = new_style_attrs(s, kind);
|
||||
s.note_journal("attrs", Value::Object(a.clone()));
|
||||
a
|
||||
}
|
||||
},
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
|
||||
|
|
|
|||
|
|
@ -444,6 +444,73 @@ mod area_tests {
|
|||
assert_eq!(old.burasagari, Burasagari::None);
|
||||
}
|
||||
|
||||
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
|
||||
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
|
||||
/// carry them; params override; the values are journaled; imported text keeps the Roman
|
||||
/// baseline.
|
||||
#[test]
|
||||
fn new_type_takes_the_japanese_defaults_while_the_interface_is_japanese() {
|
||||
use vectorcraft_doc::{CharAlign, LeadingModel};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let made = |s: &mut Session, cmd: &str, p: Value| -> (LeadingModel, CharAlign) {
|
||||
let id = s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap();
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (t.para.leading_model, t.runs[0].style.char_align),
|
||||
_ => panic!("text"),
|
||||
}
|
||||
};
|
||||
let roman = (LeadingModel::RomanBaseline, CharAlign::RomanBaseline);
|
||||
let japanese = (LeadingModel::EmBoxTop, CharAlign::EmBoxCenter);
|
||||
let point = json!({"x": 10, "y": 50, "text": "雅楽"});
|
||||
assert!(!s.japanese_interface());
|
||||
assert_eq!(made(&mut s, "text.create", point.clone()), roman, "headless, `auto`: the Roman defaults");
|
||||
s.ui_language = Some("ja".into());
|
||||
assert_eq!(made(&mut s, "text.create", point.clone()), japanese);
|
||||
assert_eq!(made(&mut s, "text.create", json!({"x": 10, "y": 90, "text": "笙", "area": {"width": 100, "height": 50}})), japanese);
|
||||
// Journaled: a replay in another language does the same.
|
||||
let (_, logged) = s.journal.last().unwrap().clone();
|
||||
assert_eq!((logged["leadingModel"].as_str(), logged["charAlign"].as_str()), (Some("emBoxTop"), Some("emBoxCenter")));
|
||||
// Params override.
|
||||
assert_eq!(
|
||||
made(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "a", "leadingModel": "romanBaseline", "charAlign": "romanBaseline"})),
|
||||
roman
|
||||
);
|
||||
assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "leadingModel": "middle"})).is_err());
|
||||
// Area type in a path.
|
||||
let path = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 100, "height": 80})).unwrap()["id"].clone();
|
||||
assert_eq!(made(&mut s, "text.createInPath", json!({"path": path, "mode": "area", "text": "篳篥"})), japanese);
|
||||
// A preference set to Japanese counts without a UI; another language doesn't.
|
||||
s.ui_language = None;
|
||||
s.prefs.interface_language = "ja".into();
|
||||
assert_eq!(made(&mut s, "text.create", point.clone()), japanese);
|
||||
s.ui_language = Some("en".into());
|
||||
assert_eq!(made(&mut s, "text.create", point.clone()), roman);
|
||||
// New styles made from nothing.
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.ui_language = Some("ja".into());
|
||||
let style = |s: &mut Session, kind: &str| {
|
||||
let name = s.execute(&format!("{kind}.new"), &json!({})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
let list = s.execute(&format!("{kind}.list"), &json!({})).unwrap();
|
||||
list["styles"].as_array().unwrap().iter().find(|st| st["name"] == name.as_str()).unwrap()["attrs"].clone()
|
||||
};
|
||||
assert_eq!(style(&mut s, "paraStyle"), json!({"leading_model": "emBoxTop"}));
|
||||
assert_eq!(style(&mut s, "charStyle"), json!({"charAlign": "emBoxCenter"}));
|
||||
s.ui_language = Some("en".into());
|
||||
assert_eq!(style(&mut s, "paraStyle"), json!({}));
|
||||
// Imported text keeps the Roman baseline.
|
||||
s.ui_language = Some("ja".into());
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="10" y="50">雅楽</text></svg>"#;
|
||||
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||||
let mut found = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
found.push((t.para.leading_model, t.runs[0].style.char_align));
|
||||
}
|
||||
});
|
||||
assert_eq!(found, [roman]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() {
|
||||
use vectorcraft_doc::LeadingModel;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -763,6 +763,10 @@ pub struct Session {
|
|||
pub style_libraries: cmd::stylelib::Libraries,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
|
||||
/// `None` without one (headless), where an explicit `interfaceLanguage` preference counts.
|
||||
/// Japanese gives new type the Japanese defaults ([`Session::japanese_interface`]).
|
||||
pub ui_language: Option<String>,
|
||||
/// Parameters the running top-level command resolved from the preferences or the clock, added
|
||||
/// to its journal entry so a replay does the same ([`Session::note_journal`]).
|
||||
journal_note: serde_json::Map<String, Value>,
|
||||
|
|
@ -825,6 +829,7 @@ impl Session {
|
|||
freeform_point: None,
|
||||
style_libraries: Default::default(),
|
||||
recent_urls: vec![],
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
note_depth: 1,
|
||||
batch_stash: None,
|
||||
|
|
@ -996,6 +1001,12 @@ impl Session {
|
|||
Ok(r)
|
||||
}
|
||||
|
||||
/// Is the interface in Japanese? Then new type starts with em box top-to-top leading and em
|
||||
/// box centre character alignment (#432).
|
||||
pub fn japanese_interface(&self) -> bool {
|
||||
self.ui_language.as_deref().unwrap_or(&self.prefs.interface_language).eq_ignore_ascii_case("ja")
|
||||
}
|
||||
|
||||
/// Record `key: value` in the running top-level command's journal entry (or its interaction's
|
||||
/// preview, or its step in a batch) unless its params give `key`: a value it resolved from the
|
||||
/// preferences or the clock.
|
||||
|
|
@ -1477,6 +1488,8 @@ mod tests_scalestrokes;
|
|||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_smartguides;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
//! Live Corners on live rectangles: `object.setLiveShape` rounds the given corners, the
|
||||
//! Direct-Selected ones or all four, sets corner kinds, and keeps selected corners selected.
|
||||
//! Live Corners: `object.setLiveShape` rounds the given corners of a live rectangle, the
|
||||
//! Direct-Selected ones or all four, sets corner kinds, and keeps selected corners selected; and
|
||||
//! does the same on any path's corners: a star, a live polygon, a pen path (#511).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind};
|
||||
use vectorcraft_doc::{AnchorRef, LiveCorners, LiveShape, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
|
||||
|
|
@ -150,3 +151,131 @@ fn corners_of_a_non_square_rectangle_are_alike_circles() {
|
|||
run(&mut s, json!({"radius": 8}));
|
||||
assert_spans(&s, id, [8.0; 4]);
|
||||
}
|
||||
|
||||
/// A selected five-pointed star at (200, 200) of radii 60 and 30: a plain path.
|
||||
fn star(s: &mut Session) -> NodeId {
|
||||
NodeId(s.execute("shape.star", &json!({"cx": 200, "cy": 200, "radius1": 60, "radius2": 30})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node_path(s: &Session, id: NodeId) -> (vectorcraft_geom::PathData, Option<LiveShape>) {
|
||||
let NodeKind::Path { path, live, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") };
|
||||
(path.clone(), live.clone())
|
||||
}
|
||||
|
||||
/// The radius and kind every corner of `id` shares, and how many corners it has.
|
||||
fn style(s: &Session, id: NodeId) -> (Option<f64>, Option<CornerKind>, usize) {
|
||||
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
|
||||
let (r, k) = c.style(&c.all());
|
||||
(r, k, c.corners.len())
|
||||
}
|
||||
|
||||
/// The radius set on each of the first `N` corners of `id`.
|
||||
fn radii<const N: usize>(s: &Session, id: NodeId) -> [f64; N] {
|
||||
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
|
||||
std::array::from_fn(|k| c.radius(k))
|
||||
}
|
||||
|
||||
/// #511: a star (a plain path) rounds all ten corners as one step, keeps them editable (a new
|
||||
/// radius cuts the outline again rather than the cut path), and is the plain star again at 0.
|
||||
#[test]
|
||||
fn a_star_rounds_its_corners_and_keeps_them_live() {
|
||||
let (mut s, _) = session();
|
||||
let id = star(&mut s);
|
||||
let (sharp, _) = node_path(&s, id);
|
||||
assert_eq!(style(&s, id), (Some(0.0), Some(CornerKind::Round), 10));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, json!({"radius": 4}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let (path, live) = node_path(&s, id);
|
||||
assert_eq!(path.anchor_count(), 20);
|
||||
assert!(matches!(&live, Some(LiveShape::Path { base, .. }) if *base == sharp), "{live:?}");
|
||||
assert_eq!(style(&s, id), (Some(4.0), Some(CornerKind::Round), 10));
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().kind_label(), "Path");
|
||||
run(&mut s, json!({"radius": 6, "kind": "chamfer"}));
|
||||
assert_eq!((node_path(&s, id).0.anchor_count(), style(&s, id)), (20, (Some(6.0), Some(CornerKind::Chamfer), 10)));
|
||||
run(&mut s, json!({"radius": 0, "kind": "round"}));
|
||||
assert_eq!(node_path(&s, id), (sharp, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(style(&s, id).0, Some(6.0));
|
||||
}
|
||||
|
||||
/// Each corner stops at its own limit: its cut reaches halfway along its shorter side, so a
|
||||
/// star's neighbouring corners meet without overlapping.
|
||||
#[test]
|
||||
fn star_corners_stop_at_their_own_limit() {
|
||||
let (mut s, _) = session();
|
||||
let id = star(&mut s);
|
||||
run(&mut s, json!({"radius": 1000}));
|
||||
let (path, live) = node_path(&s, id);
|
||||
let Some(LiveShape::Path { base, .. }) = live else { panic!("live corners") };
|
||||
// Every cut ends at the middle of a side: each side's two cuts meet there.
|
||||
let sp = &base.subpaths[0];
|
||||
for (i, a) in sp.anchors.iter().enumerate() {
|
||||
let mid = a.p.midpoint(sp.anchors[(i + 1) % 10].p);
|
||||
assert!(path.anchors().any(|(_, _, b)| b.p.distance(mid) < 1e-9), "a cut ends at {mid:?}");
|
||||
}
|
||||
assert_eq!(style(&s, id).0, Some(1000.0), "the radius set is kept; it draws as large as fits");
|
||||
}
|
||||
|
||||
/// #511: Direct Selection picks one of the star's anchors: that corner alone rounds, and both of
|
||||
/// its new anchors stay selected.
|
||||
#[test]
|
||||
fn a_direct_selected_star_anchor_rounds_its_corner_alone() {
|
||||
let (mut s, _) = session();
|
||||
let id = star(&mut s);
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"})).unwrap();
|
||||
run(&mut s, json!({"radius": 5}));
|
||||
assert_eq!(node_path(&s, id).0.anchor_count(), 11);
|
||||
// The top tip's cut ends the path and starts it.
|
||||
assert_eq!(selected_anchors(&s, id), Some(anchors(&[0, 10])));
|
||||
assert_eq!(radii::<2>(&s, id), [5.0, 0.0]);
|
||||
// Explicit corners, as MCP and the widgets give them.
|
||||
run(&mut s, json!({"id": id.0, "corners": [1, 3], "radius": 2}));
|
||||
assert_eq!(radii::<4>(&s, id), [5.0, 2.0, 0.0, 2.0]);
|
||||
// Past the outline's last anchor is an error, and nothing changes.
|
||||
assert!(s.execute("object.setLiveShape", &json!({"id": id.0, "corners": [10], "radius": 1})).is_err());
|
||||
assert_eq!(node_path(&s, id).0.anchor_count(), 13);
|
||||
}
|
||||
|
||||
/// A live polygon stays live with cut corners, which keep their radius as its sides change and
|
||||
/// stay circular when it is scaled unevenly (the radius scales by the mean scale).
|
||||
#[test]
|
||||
fn a_polygon_keeps_its_corners_through_sides_and_scales() {
|
||||
let (mut s, _) = session();
|
||||
let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap());
|
||||
run(&mut s, json!({"radius": 10}));
|
||||
let (path, live) = node_path(&s, id);
|
||||
assert_eq!(path.anchor_count(), 12);
|
||||
assert!(matches!(live, Some(LiveShape::Polygon { sides: 6, .. })));
|
||||
run(&mut s, json!({"sides": 8}));
|
||||
assert_eq!((node_path(&s, id).0.anchor_count(), style(&s, id)), (16, (Some(10.0), Some(CornerKind::Round), 8)));
|
||||
s.execute("object.scale", &json!({"sx": 400, "sy": 100, "corners": true})).unwrap();
|
||||
assert!(matches!(node_path(&s, id).1, Some(LiveShape::Polygon { .. })));
|
||||
assert!((style(&s, id).0.unwrap() - 20.0).abs() < 1e-9);
|
||||
// Every cut is a circle of that radius: its ends are as far from its centre.
|
||||
let path = node_path(&s, id).0;
|
||||
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
|
||||
for k in &c.corners {
|
||||
let centre = k.on_bisector(20.0 / k.sin());
|
||||
let ends = path.anchors().filter(|(_, _, a)| (a.p.distance(centre) - 20.0).abs() < 1e-6).count();
|
||||
assert_eq!(ends, 2, "corner {} is circular", k.index);
|
||||
}
|
||||
}
|
||||
|
||||
/// #511: a pen path's corners round; its ends don't. An ellipse has no corners to round.
|
||||
#[test]
|
||||
fn a_pen_path_rounds_its_corners_but_not_its_ends() {
|
||||
let (mut s, _) = session();
|
||||
let pen = json!({"anchors": [{"x": 10, "y": 200}, {"x": 110, "y": 100}, {"x": 210, "y": 200}, {"x": 310, "y": 100}]});
|
||||
let id = NodeId(s.execute("path.create", &pen).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
run(&mut s, json!({"radius": 8}));
|
||||
let path = node_path(&s, id).0;
|
||||
assert_eq!(path.anchor_count(), 6);
|
||||
let sp = &path.subpaths[0];
|
||||
assert_eq!((sp.anchors[0].p.x, sp.anchors[5].p.x), (10.0, 310.0), "the ends stay");
|
||||
let e = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 30})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = node_path(&s, e);
|
||||
run(&mut s, json!({"id": e.0, "radius": 8}));
|
||||
assert_eq!(node_path(&s, e), before);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -761,7 +761,7 @@ impl Interp<'_> {
|
|||
let Some(mut img) = self.decode_image(image)? else { return Ok(None) };
|
||||
let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) };
|
||||
let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize);
|
||||
for (i, px) in img.rgba.chunks_exact_mut(4).enumerate() {
|
||||
for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
// The mask's pixel over this one (in 64 bits: rows times rows overflow 32).
|
||||
let at = (y as u64 * mh as u64 / h.max(1) as u64) * mw as u64 + x as u64 * mw as u64 / w.max(1) as u64;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
//! Live Corners on any path: the anchors where two straight sides meet at an angle, and the path
|
||||
//! with those corners cut round, inverted round or chamfered. Rectangles cut theirs the same way.
|
||||
|
||||
use kurbo::{Point, Vec2};
|
||||
|
||||
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
|
||||
use crate::shapes::{CornerKind, KAPPA};
|
||||
|
||||
/// Lengths and radii at or below this are none at all.
|
||||
const EPS: f64 = 1e-9;
|
||||
/// Sides closer than this to straight on (or folded back), as the sine of their angle, make no corner.
|
||||
const MIN_SINE: f64 = 1e-6;
|
||||
|
||||
/// A corner Live Corners can cut: an anchor without handles between two straight sides that
|
||||
/// meet at an angle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Corner {
|
||||
/// The anchor's index in its path, counting the anchors of every subpath in order.
|
||||
pub index: usize,
|
||||
/// Where the sides meet.
|
||||
pub at: Point,
|
||||
/// Unit directions along the sides: towards the previous anchor, then towards the next one.
|
||||
pub u: Vec2,
|
||||
pub v: Vec2,
|
||||
/// The sides' lengths, in the same order.
|
||||
pub lu: f64,
|
||||
pub lv: f64,
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// The corner of anchor `i` of `sp`, whose index in the path is `index`; `None` when the
|
||||
/// anchor is an end of an open subpath, has handles or is smooth, when a side curves or has
|
||||
/// no length, or when the sides run straight on.
|
||||
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
|
||||
let n = sp.anchors.len();
|
||||
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
|
||||
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
|
||||
if a.kind == AnchorKind::Smooth || a.has_in() || a.has_out() || prev.has_out() || next.has_in() {
|
||||
return None;
|
||||
}
|
||||
let (du, dv) = (prev.p - a.p, next.p - a.p);
|
||||
let (lu, lv) = (du.hypot(), dv.hypot());
|
||||
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let c = Self { index, at: a.p, u: du / lu, v: dv / lv, lu, lv };
|
||||
(c.sin() > MIN_SINE).then_some(c)
|
||||
}
|
||||
|
||||
/// The cosine of the angle between the sides.
|
||||
pub fn cos(&self) -> f64 {
|
||||
self.u.dot(self.v)
|
||||
}
|
||||
|
||||
/// The sine of the angle between the sides.
|
||||
pub fn sin(&self) -> f64 {
|
||||
self.u.cross(self.v).abs()
|
||||
}
|
||||
|
||||
/// The angle between the sides, in degrees (90 at a rectangle's corners).
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
self.sin().atan2(self.cos()).to_degrees()
|
||||
}
|
||||
|
||||
/// How far from the corner a round cut of radius `r` meets each side: r / tan(angle / 2).
|
||||
pub fn setback(&self, r: f64) -> f64 {
|
||||
r * (1.0 + self.cos()) / self.sin()
|
||||
}
|
||||
|
||||
/// The largest radius the corner draws: its cut then reaches halfway along the shorter side,
|
||||
/// so neighbouring corners at their largest meet without overlapping (half the shorter side
|
||||
/// at a rectangle's corners).
|
||||
pub fn max_radius(&self) -> f64 {
|
||||
self.lu.min(self.lv) / 2.0 * self.sin() / (1.0 + self.cos())
|
||||
}
|
||||
|
||||
/// The radius the corner is drawn with: `radius`, no larger than [`Corner::max_radius`], 0 when
|
||||
/// negative or not a number.
|
||||
pub fn fitted(&self, radius: f64) -> f64 {
|
||||
radius.max(0.0).min(self.max_radius())
|
||||
}
|
||||
|
||||
/// The point `k` along the bisector, measured in (u + v): the centre of a round cut of radius
|
||||
/// `r` is at `k = r / sin`.
|
||||
pub fn on_bisector(&self, k: f64) -> Point {
|
||||
self.at + (self.u + self.v) * k
|
||||
}
|
||||
|
||||
/// How much the radius grows when the centre of the round cut moves by `d`: `d`'s part along
|
||||
/// the bisector, in radius units.
|
||||
pub fn radius_change(&self, d: Vec2) -> f64 {
|
||||
d.dot(self.u + self.v) * self.sin() / (2.0 + 2.0 * self.cos())
|
||||
}
|
||||
|
||||
/// The two anchors replacing the corner when cut with `radius` (> 0, fitted): where the cut
|
||||
/// meets the arriving side, then the leaving one.
|
||||
fn cut(&self, radius: f64, kind: CornerKind) -> [Anchor; 2] {
|
||||
let t = self.setback(radius);
|
||||
let (start, end) = (self.at + self.u * t, self.at + self.v * t);
|
||||
let (h_out, h_in) = match kind {
|
||||
// An arc of `radius` tangent to both sides, turning by 180° less the angle.
|
||||
CornerKind::Round => {
|
||||
let h = radius * arc_handle(-self.cos());
|
||||
(start - self.u * h, end - self.v * h)
|
||||
}
|
||||
// An arc centred on the corner through both ends, spanning the angle.
|
||||
CornerKind::InvertedRound => {
|
||||
let h = t * arc_handle(self.cos());
|
||||
(start + square_to(self.u, self.v) * h, end + square_to(self.v, self.u) * h)
|
||||
}
|
||||
CornerKind::Chamfer => (start, end),
|
||||
};
|
||||
[Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner }, Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner }]
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit direction square to unit `a`, on `b`'s side.
|
||||
fn square_to(a: Vec2, b: Vec2) -> Vec2 {
|
||||
let w = b - a * a.dot(b);
|
||||
w / w.hypot()
|
||||
}
|
||||
|
||||
/// The handle length, per unit of radius, of a cubic drawing a circular arc that spans an angle
|
||||
/// whose cosine is `cos`: 4/3 · tan(angle / 4) ([`KAPPA`] for a quarter circle).
|
||||
fn arc_handle(cos: f64) -> f64 {
|
||||
if cos.abs() < 1e-12 {
|
||||
return KAPPA;
|
||||
}
|
||||
let (sin_half, cos_half) = (((1.0 - cos) / 2.0).max(0.0).sqrt(), ((1.0 + cos) / 2.0).max(0.0).sqrt());
|
||||
4.0 / 3.0 * sin_half / (1.0 + cos_half)
|
||||
}
|
||||
|
||||
/// The corners of `path` Live Corners can cut, in anchor order.
|
||||
pub fn path_corners(path: &PathData) -> Vec<Corner> {
|
||||
let mut out = vec![];
|
||||
let mut first = 0;
|
||||
for sp in &path.subpaths {
|
||||
out.extend((0..sp.anchors.len()).filter_map(|i| Corner::of(sp, i, first + i)));
|
||||
first += sp.anchors.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
|
||||
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
|
||||
/// ([`Corner::fitted`]). A cut corner becomes two anchors; a closed subpath whose first corner
|
||||
/// is cut starts where that cut ends, so the cut closes it. Also returns, per subpath, the
|
||||
/// anchor index in `path` each anchor comes from.
|
||||
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
|
||||
let corners = path_corners(path);
|
||||
let mut corners = corners.iter().peekable();
|
||||
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
|
||||
let mut first = 0;
|
||||
for sp in &path.subpaths {
|
||||
let (mut anchors, mut from) = (Vec::with_capacity(sp.anchors.len()), Vec::with_capacity(sp.anchors.len()));
|
||||
let mut closing = None;
|
||||
for (i, a) in sp.anchors.iter().enumerate() {
|
||||
let index = first + i;
|
||||
let corner = corners.next_if(|c| c.index == index);
|
||||
let radius = corner.map_or(0.0, |c| c.fitted(radii.get(index).copied().unwrap_or(0.0)));
|
||||
match corner {
|
||||
Some(c) if radius > EPS => {
|
||||
let [start, end] = c.cut(radius, kinds.get(index).copied().unwrap_or_default());
|
||||
if i == 0 && sp.closed {
|
||||
closing = Some(start);
|
||||
} else {
|
||||
anchors.push(start);
|
||||
from.push(index);
|
||||
}
|
||||
anchors.push(end);
|
||||
from.push(index);
|
||||
}
|
||||
_ => {
|
||||
anchors.push(*a);
|
||||
from.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(a) = closing {
|
||||
anchors.push(a);
|
||||
from.push(first);
|
||||
}
|
||||
first += sp.anchors.len();
|
||||
subpaths.push(SubPath::new(anchors, sp.closed));
|
||||
sources.push(from);
|
||||
}
|
||||
(PathData::new(subpaths), sources)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
use kurbo::Rect;
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
/// A five-pointed star's tips are acute corners, its inner corners obtuse: all ten cut, each
|
||||
/// a circle of the radius tangent to both sides.
|
||||
#[test]
|
||||
fn a_star_has_ten_corners_cut_as_circles() {
|
||||
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
|
||||
let corners = path_corners(&star);
|
||||
assert_eq!(corners.len(), 10);
|
||||
let angles: Vec<_> = corners.iter().map(|c| c.angle_deg()).collect();
|
||||
assert!(angles.iter().step_by(2).all(|a| *a < 90.0), "tips are acute: {angles:?}");
|
||||
assert!(angles.iter().skip(1).step_by(2).all(|a| *a > 90.0), "inner corners are obtuse: {angles:?}");
|
||||
let (cut, from) = cut_corners(&star, &[4.0; 10], &[]);
|
||||
assert_eq!(cut.anchor_count(), 20);
|
||||
assert_eq!(from, [vec![0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 0]]);
|
||||
for c in &corners {
|
||||
// Both ends of the cut lie `setback` along the sides, as far from the arc's centre as
|
||||
// the radius, and that centre is `radius` from either side.
|
||||
let t = c.setback(4.0);
|
||||
let o = c.on_bisector(4.0 / c.sin());
|
||||
for p in [c.at + c.u * t, c.at + c.v * t] {
|
||||
assert!((p.distance(o) - 4.0).abs() < 1e-9);
|
||||
assert!(cut.anchors().any(|(_, _, a)| near(a.p, p)), "{p:?} is an anchor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radii_stop_at_half_the_shorter_side_per_corner() {
|
||||
let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(0.0, 40.0)], true));
|
||||
let corners = path_corners(&tri);
|
||||
assert_eq!(corners.len(), 3);
|
||||
for c in &corners {
|
||||
let max = c.max_radius();
|
||||
assert!((c.setback(max) - c.lu.min(c.lv) / 2.0).abs() < 1e-9);
|
||||
assert_eq!(c.fitted(1e6), max);
|
||||
assert_eq!(c.fitted(-3.0), 0.0);
|
||||
assert_eq!(c.fitted(f64::NAN), 0.0);
|
||||
}
|
||||
// The right angle at (0, 0) takes up to 20, half its shorter side.
|
||||
assert!((corners[0].max_radius() - 20.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ends_curves_smooth_and_straight_anchors_are_no_corners() {
|
||||
// An open zig-zag: its ends aren't corners, nor is the anchor its sides run straight on through.
|
||||
let open = PathData::single(SubPath::polyline(
|
||||
&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(150.0, 80.0)],
|
||||
false,
|
||||
));
|
||||
assert_eq!(path_corners(&open).iter().map(|c| c.index).collect::<Vec<_>>(), [2, 3]);
|
||||
// A curve on either side, or handles on the anchor, leave it alone.
|
||||
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
||||
sp.anchors[1].h_out = Point::new(110.0, 10.0);
|
||||
let p = PathData::single(sp);
|
||||
assert_eq!(path_corners(&p).iter().map(|c| c.index).collect::<Vec<_>>(), [0, 3]);
|
||||
assert!(path_corners(&shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))).is_empty());
|
||||
// A second subpath counts on from the first one's anchors.
|
||||
let two = PathData::new(vec![
|
||||
SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
SubPath::polyline(&[Point::new(50.0, 0.0), Point::new(60.0, 0.0), Point::new(50.0, 10.0)], true),
|
||||
]);
|
||||
let (cut, from) = cut_corners(&two, &[0.0, 0.0, 0.0, 1.0], &[]);
|
||||
assert_eq!(from, [vec![0, 1, 2], vec![3, 4, 5, 3]]);
|
||||
assert_eq!(cut.anchor_count(), 7);
|
||||
}
|
||||
|
||||
/// Inverted round and chamfer cuts end where the round one does, at any angle.
|
||||
#[test]
|
||||
fn kinds_share_their_ends() {
|
||||
let star = shapes::star(Point::new(0.0, 0.0), 50.0, 25.0, 5, 0.0);
|
||||
let ends = |k: CornerKind| -> Vec<Point> { cut_corners(&star, &[3.0; 10], &[k; 10]).0.anchors().map(|(_, _, a)| a.p).collect() };
|
||||
let round = ends(CornerKind::Round);
|
||||
assert_eq!(ends(CornerKind::InvertedRound), round);
|
||||
assert_eq!(ends(CornerKind::Chamfer), round);
|
||||
// The inverted arc stays on the circle about the corner.
|
||||
let c = path_corners(&star)[0];
|
||||
let [s, e] = c.cut(3.0, CornerKind::InvertedRound);
|
||||
let bez = kurbo::CubicBez::new(s.p, s.h_out, e.h_in, e.p);
|
||||
let r = c.setback(3.0);
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
use kurbo::ParamCurve;
|
||||
assert!((bez.eval(t).distance(c.at) - r).abs() < r * 1e-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod arc;
|
||||
pub mod bez;
|
||||
pub mod corners;
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
pub mod projective;
|
||||
|
|
|
|||
|
|
@ -62,52 +62,10 @@ pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 {
|
|||
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
|
||||
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
|
||||
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
|
||||
/// top-left corner, its anchor on the top side first.
|
||||
/// top-left corner, its anchor on the top side first. Cut as any path's corners are
|
||||
/// ([`crate::corners::cut_corners`]).
|
||||
pub fn rectangle_with_corners(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> PathData {
|
||||
PathData::single(SubPath::new(corner_anchors(r, radii, kinds).into_iter().map(|(_, a)| a).collect(), true))
|
||||
}
|
||||
|
||||
/// The corner (index into `radii`) of each anchor of [`rectangle_with_corners`].
|
||||
pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec<usize> {
|
||||
corner_anchors(r, radii, [CornerKind::Round; 4]).into_iter().map(|(k, _)| k).collect()
|
||||
}
|
||||
|
||||
/// The anchors of [`rectangle_with_corners`], each with its corner.
|
||||
fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usize, Anchor)> {
|
||||
let r = r.abs();
|
||||
// Each corner, with the directions of the side arriving at it and of the side leaving it.
|
||||
let corners = [
|
||||
(Point::new(r.x0, r.y0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0)),
|
||||
(Point::new(r.x1, r.y0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)),
|
||||
(Point::new(r.x1, r.y1), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0)),
|
||||
(Point::new(r.x0, r.y1), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0)),
|
||||
];
|
||||
let mut out = Vec::with_capacity(8);
|
||||
let mut top_left_start = None;
|
||||
for (k, ((c, din, dout), (rad, kind))) in corners.into_iter().zip(radii.into_iter().zip(kinds)).enumerate() {
|
||||
let rad = fitted_corner_radius(r.width(), r.height(), rad);
|
||||
if rad <= 1e-9 {
|
||||
out.push((k, Anchor::corner(c)));
|
||||
continue;
|
||||
}
|
||||
// The cut runs from `start` on the arriving side to `end` on the leaving one.
|
||||
let (start, end, h) = (c - din * rad, c + dout * rad, rad * KAPPA);
|
||||
let (h_out, h_in) = match kind {
|
||||
CornerKind::Round => (start + din * h, end - dout * h),
|
||||
CornerKind::InvertedRound => (start + dout * h, end - din * h),
|
||||
CornerKind::Chamfer => (start, end),
|
||||
};
|
||||
let start = Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner };
|
||||
let end = Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner };
|
||||
if k == 0 {
|
||||
top_left_start = Some(start);
|
||||
} else {
|
||||
out.push((k, start));
|
||||
}
|
||||
out.push((k, end));
|
||||
}
|
||||
out.extend(top_left_start.map(|a| (0, a)));
|
||||
out
|
||||
crate::corners::cut_corners(&rectangle(r), &radii, &kinds).0
|
||||
}
|
||||
|
||||
/// Ellipse inscribed in `r` with 4 smooth anchors (left, top, right, bottom).
|
||||
|
|
@ -244,6 +202,11 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The corner (index into `radii`) of each anchor of [`rectangle_with_corners`].
|
||||
fn corner_sources(r: Rect, radii: [f64; 4]) -> Vec<usize> {
|
||||
crate::corners::cut_corners(&rectangle(r), &radii, &[]).1.concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_is_4_corners_clockwise() {
|
||||
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
|
|
@ -293,7 +256,7 @@ mod tests {
|
|||
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
|
||||
];
|
||||
assert_eq!(rounded_rectangle(r, rad).subpaths[0].anchors, want);
|
||||
assert_eq!(rectangle_anchor_corners(r, [rad; 4]), [0, 1, 1, 2, 2, 3, 3, 0]);
|
||||
assert_eq!(corner_sources(r, [rad; 4]), [0, 1, 1, 2, 2, 3, 3, 0]);
|
||||
assert_eq!(rounded_rectangle(r, 0.0), rectangle(r));
|
||||
}
|
||||
|
||||
|
|
@ -305,11 +268,11 @@ mod tests {
|
|||
let p = rectangle_with_corners(r, radii, [CornerKind::Round; 4]);
|
||||
let pts: Vec<Point> = p.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts, [Point::new(0.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 10.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)]);
|
||||
assert_eq!(rectangle_anchor_corners(r, radii), [0, 1, 1, 2, 3]);
|
||||
assert_eq!(corner_sources(r, radii), [0, 1, 1, 2, 3]);
|
||||
let quarter = PI * 25.0;
|
||||
assert!((p.subpaths[0].area() - (6000.0 - 100.0 + quarter)).abs() < 0.1, "a quarter circle off one corner");
|
||||
// The top-left corner rounded: its top-side anchor first, its left-side one last.
|
||||
assert_eq!(rectangle_anchor_corners(r, [10.0, 0.0, 0.0, 200.0]), [0, 1, 2, 3, 3, 0]);
|
||||
assert_eq!(corner_sources(r, [10.0, 0.0, 0.0, 200.0]), [0, 1, 2, 3, 3, 0]);
|
||||
// Inverted round cuts a quarter circle out; a chamfer cuts a triangle.
|
||||
let inverted = rectangle_with_corners(r, radii, [CornerKind::InvertedRound; 4]);
|
||||
assert!((inverted.subpaths[0].area() - (6000.0 - quarter)).abs() < 0.1);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -621,7 +621,9 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
|
|||
let sweep = (pie.1 - pie.0).abs();
|
||||
(sweep < 1e-6 || (sweep - 360.0).abs() < 1e-6).then_some(Corners::Ellipse)
|
||||
}
|
||||
Some(LiveShape::Rectangle { .. } | LiveShape::Ellipse { .. } | LiveShape::Polygon { .. } | LiveShape::Line { .. }) => None,
|
||||
Some(
|
||||
LiveShape::Rectangle { .. } | LiveShape::Ellipse { .. } | LiveShape::Polygon { .. } | LiveShape::Line { .. } | LiveShape::Path { .. },
|
||||
) => None,
|
||||
None => is_upright_rectangle(path).then_some(Corners::Square),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,187 +1,231 @@
|
|||
//! Live Corners: the widgets inside the corners of a selected live rectangle. Dragging one rounds
|
||||
//! (or sharpens) the corners whose widgets show together: all four when the whole shape is
|
||||
//! selected, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget
|
||||
//! cycles their corner kind (round, inverted round, chamfer); double-clicking one opens the
|
||||
//! Corners dialog. The Selection and Direct Selection tools share this, and the canvas draws the
|
||||
//! widgets from the same geometry.
|
||||
//! Live Corners: the widgets inside the corners of a selected path. The Selection tool shows them
|
||||
//! on a live rectangle or polygon, Direct Selection on any path (a star, a pen path) at each corner
|
||||
//! anchor: one without handles between two straight sides. Dragging one rounds (or sharpens) the
|
||||
//! corners whose widgets show together: every corner when the whole path is selected, those with a
|
||||
//! selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
|
||||
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
|
||||
//! Selection and Direct Selection tools share this, and the canvas draws the widgets from the same
|
||||
//! geometry.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_doc::{Document, LiveCorners, LiveShape, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||||
|
||||
/// The dialog a double-click on a corner widget opens: `{id, corners}`.
|
||||
pub const DIALOG: &str = "corners";
|
||||
|
||||
/// Widgets sit at least this far inside their corner (screen px), further in once the radius is.
|
||||
/// Widgets sit at least this far inside their corner along each side of a right angle (screen px;
|
||||
/// √2 times it along the bisector, at any angle), further in once the radius is.
|
||||
const MIN_INSET_PX: f64 = 10.0;
|
||||
/// Shapes whose shorter side is smaller than this on screen (px) hide their widgets.
|
||||
/// Corners with a side shorter than this on screen (px) hide their widgets.
|
||||
const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX;
|
||||
/// Corners in radii order (top-left, top-right, bottom-right, bottom-left): position as factors
|
||||
/// of (w, h) and the inward diagonal.
|
||||
const CORNERS: [((f64, f64), (f64, f64)); 4] =
|
||||
[((0.0, 0.0), (1.0, 1.0)), ((1.0, 0.0), (-1.0, 1.0)), ((1.0, 1.0), (-1.0, -1.0)), ((0.0, 1.0), (1.0, -1.0))];
|
||||
|
||||
/// The corner widgets of a selection that is exactly one editable live rectangle.
|
||||
/// One corner's widget.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Widget {
|
||||
/// The corner, in the corners' own space.
|
||||
corner: Corner,
|
||||
/// The radius it is set to (document units) and its kind.
|
||||
radius: f64,
|
||||
kind: CornerKind,
|
||||
/// The angle between its sides in the document, in degrees.
|
||||
angle: f64,
|
||||
/// The widget's centre in the document.
|
||||
at: Point,
|
||||
}
|
||||
|
||||
/// The corner widgets of a selection that is exactly one editable path with corners.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CornerWidgets {
|
||||
pub id: NodeId,
|
||||
w: f64,
|
||||
h: f64,
|
||||
radii: [f64; 4],
|
||||
kinds: [CornerKind; 4],
|
||||
/// Maps the corners' space into the document (a live rectangle's own; else none).
|
||||
xf: Affine,
|
||||
/// Widget centres in document coordinates, in radii order.
|
||||
pub points: [Point; 4],
|
||||
/// The corners whose widgets show (and which a drag edits).
|
||||
pub shown: [bool; 4],
|
||||
/// The widgets that show (and which a drag edits), in anchor order.
|
||||
widgets: Vec<Widget>,
|
||||
/// Every corner of the path shows its widget.
|
||||
all: bool,
|
||||
}
|
||||
|
||||
impl CornerWidgets {
|
||||
/// The widgets at `zoom` (screen px per document point), if the selection has them.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
|
||||
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
|
||||
let [id] = selection.objects[..] else { return None };
|
||||
if !doc.is_editable(id) {
|
||||
return None;
|
||||
}
|
||||
let Some(NodeKind::Path { live: Some(live), .. }) = doc.node(id).map(|n| &n.kind) else { return None };
|
||||
// In document units, as `object.setLiveShape` edits them.
|
||||
let LiveShape::Rectangle { w, h, radii, kinds, xf } = live.folded() else { return None };
|
||||
// Screen pixels per shape unit along each side (the shape may be scaled or skewed).
|
||||
let c = xf.as_coeffs();
|
||||
let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom);
|
||||
if xf.determinant().abs() < 1e-12 || (w * px).min(h * py) < MIN_SIDE_PX {
|
||||
let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None };
|
||||
if !any_path && !matches!(live, Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) {
|
||||
return None;
|
||||
}
|
||||
// Each widget sits on its corner's diagonal, at the centre of the arc as drawn.
|
||||
let points = std::array::from_fn(|k| {
|
||||
let ((fx, fy), (sx, sy)) = CORNERS[k];
|
||||
let r = shapes::fitted_corner_radius(w, h, radii[k]);
|
||||
let ix = r.max(MIN_INSET_PX / px).min(w / 2.0);
|
||||
let iy = r.max(MIN_INSET_PX / py).min(h / 2.0);
|
||||
xf * Point::new(fx * w + sx * ix, fy * h + sy * iy)
|
||||
});
|
||||
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
|
||||
let lc = LiveCorners::new(path, live.as_ref())?;
|
||||
let det = lc.xf.determinant().abs();
|
||||
if det < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// Screen pixels per unit of the corners' space (a rectangle's may be skewed).
|
||||
let scale = zoom * det.sqrt();
|
||||
let picked = lc.picked(selection.partial(id));
|
||||
let widgets: Vec<_> = lc
|
||||
.corners
|
||||
.iter()
|
||||
.filter(|c| picked.contains(&c.index) && c.lu.min(c.lv) * scale >= MIN_SIDE_PX)
|
||||
.map(|c| {
|
||||
// On the bisector at the centre of the arc as drawn, at least the minimum inset
|
||||
// in and at most half the shorter side.
|
||||
let radius = lc.radius(c.index);
|
||||
let k = (c.fitted(radius) / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0);
|
||||
Widget { corner: *c, radius, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) }
|
||||
})
|
||||
.collect();
|
||||
let all = widgets.len() == lc.corners.len();
|
||||
(!widgets.is_empty()).then_some(Self { id, xf: lc.xf, widgets, all })
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
|
||||
/// corners wider than `max` degrees (a skewed rectangle's obtuse ones) hide; none if all do.
|
||||
/// corners wider than `max` degrees (in the document) hide; none if all do.
|
||||
pub fn within_angle(mut self, max: f64) -> Option<Self> {
|
||||
// The angle between the shape's sides where they meet at the top-left corner; the
|
||||
// top-right and bottom-left corners are its supplement.
|
||||
let c = self.xf.as_coeffs();
|
||||
let (u, v) = (Vec2::new(c[0], c[1]), Vec2::new(c[2], c[3]));
|
||||
let a = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
|
||||
for (k, shown) in self.shown.iter_mut().enumerate() {
|
||||
let angle = if k % 2 == 0 { a } else { 180.0 - a };
|
||||
*shown &= angle <= max + 1e-9;
|
||||
}
|
||||
self.shown.contains(&true).then_some(self)
|
||||
let n = self.widgets.len();
|
||||
self.widgets.retain(|w| w.angle <= max + 1e-9);
|
||||
self.all &= self.widgets.len() == n;
|
||||
(!self.widgets.is_empty()).then_some(self)
|
||||
}
|
||||
|
||||
/// The widgets the active tool can drag (View → Show Corner Widget on).
|
||||
pub fn for_tool(cx: &ToolContext) -> Option<Self> {
|
||||
if !cx.corner_widgets {
|
||||
/// The widgets on show: [`Self::of`] with View → Show Corner Widget `on`, less those wider
|
||||
/// than `max_angle` ([`Self::within_angle`]).
|
||||
pub fn showing(doc: &Document, selection: &Selection, zoom: f64, any_path: bool, on: bool, max_angle: f64) -> Option<Self> {
|
||||
if !on {
|
||||
return None;
|
||||
}
|
||||
Self::of(cx.doc, cx.selection, cx.zoom)?.within_angle(cx.corner_widget_max_angle)
|
||||
Self::of(doc, selection, zoom, any_path)?.within_angle(max_angle)
|
||||
}
|
||||
|
||||
/// The widgets the active tool can drag (Direct Selection: `any_path`).
|
||||
pub fn for_tool(cx: &ToolContext, any_path: bool) -> Option<Self> {
|
||||
Self::showing(cx.doc, cx.selection, cx.zoom, any_path, cx.corner_widgets, cx.corner_widget_max_angle)
|
||||
}
|
||||
|
||||
/// The centres of the widgets that show.
|
||||
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
|
||||
self.points.iter().zip(self.shown).filter(|(_, on)| *on).map(|(p, _)| *p)
|
||||
self.widgets.iter().map(|w| w.at)
|
||||
}
|
||||
|
||||
/// The indices of the shown corners.
|
||||
fn shown_indices(&self) -> Value {
|
||||
(0..4).filter(|k| self.shown[*k]).collect()
|
||||
/// The corners whose widgets show: anchor indices of the path's uncut outline.
|
||||
pub fn corners(&self) -> Vec<usize> {
|
||||
self.widgets.iter().map(|w| w.corner.index).collect()
|
||||
}
|
||||
|
||||
/// Index of the shown widget nearest to `p` within `tol` (document units).
|
||||
pub fn hit(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
let d = |k: &usize| self.points[*k].distance(p);
|
||||
(0..4).filter(|k| self.shown[*k] && d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
/// The widget nearest to `p` within `tol` (document units).
|
||||
fn hit(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
let d = |k: &usize| self.widgets.get(*k).map_or(f64::INFINITY, |w| w.at.distance(p));
|
||||
(0..self.widgets.len()).filter(|k| d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
}
|
||||
|
||||
/// `object.setLiveShape` params setting `key` on the shown corners (all four unless some are
|
||||
/// hidden, which `corners` then leaves out).
|
||||
/// `object.setLiveShape` params setting `key` on the shown corners (every corner unless some
|
||||
/// are hidden, which `corners` then leaves out).
|
||||
fn command(&self, key: &str, value: Value) -> Value {
|
||||
let mut p = Map::new();
|
||||
p.insert("id".into(), json!(self.id.0));
|
||||
p.insert(key.into(), value);
|
||||
if self.shown != [true; 4] {
|
||||
p.insert("corners".into(), self.shown_indices());
|
||||
if !self.all {
|
||||
p.insert("corners".into(), json!(self.corners()));
|
||||
}
|
||||
Value::Object(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over a corner widget the active tool would drag?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
|
||||
CornerWidgets::for_tool(cx).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
|
||||
/// The angle between corner `c`'s sides once `xf` maps them into the document, in degrees.
|
||||
fn angle_in(xf: Affine, c: &Corner) -> f64 {
|
||||
let [a, b, cc, d, _, _] = xf.as_coeffs();
|
||||
let map = |v: Vec2| Vec2::new(a * v.x + cc * v.y, b * v.x + d * v.y);
|
||||
let (u, v) = (map(c.u), map(c.v));
|
||||
u.cross(v).abs().atan2(u.dot(v)).to_degrees()
|
||||
}
|
||||
|
||||
/// Is `p` over a corner widget the active tool would drag (Direct Selection: `any_path`)?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point, any_path: bool) -> bool {
|
||||
CornerWidgets::for_tool(cx, any_path).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
|
||||
}
|
||||
|
||||
/// A double-click on a corner widget opens the Corners dialog for the shown corners.
|
||||
pub fn double_click(cx: &ToolContext, p: Point) -> Option<Action> {
|
||||
let w = CornerWidgets::for_tool(cx)?;
|
||||
pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action> {
|
||||
let w = CornerWidgets::for_tool(cx, any_path)?;
|
||||
w.hit(p, cx.tol(5.0))?;
|
||||
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.shown_indices() })))
|
||||
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.corners() })))
|
||||
}
|
||||
|
||||
/// Dragging a corner widget: the radius follows the pointer along the corner's diagonal. An
|
||||
/// Dragging a corner widget: the radius follows the pointer along the corner's bisector. An
|
||||
/// Alt-click (no drag) cycles the corner kind instead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CornerDrag {
|
||||
widgets: CornerWidgets,
|
||||
corner: usize,
|
||||
/// The dragged widget (index into the shown ones).
|
||||
widget: usize,
|
||||
/// The path's uncut outline with its corners' radii and kinds (to outline the corners at
|
||||
/// their largest).
|
||||
base: PathData,
|
||||
radii: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
start: Point,
|
||||
began: bool,
|
||||
alt: bool,
|
||||
radius: f64,
|
||||
/// The pointer went past fully round: the corners are at their largest radius.
|
||||
/// The pointer went past fully round: the dragged corner is at its largest radius.
|
||||
at_limit: bool,
|
||||
at: Point,
|
||||
}
|
||||
|
||||
impl CornerDrag {
|
||||
/// Start a drag when the press `ev` is on a widget of the selection.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let (widgets, p) = (CornerWidgets::for_tool(cx)?, ev.pos);
|
||||
let corner = widgets.hit(p, cx.tol(5.0))?;
|
||||
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at_limit: false, at: p })
|
||||
/// Start a drag when the press `ev` is on a widget of the selection (Direct Selection:
|
||||
/// `any_path`).
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent, any_path: bool) -> Option<Self> {
|
||||
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
|
||||
let widget = widgets.hit(p, cx.tol(5.0))?;
|
||||
let radius = widgets.widgets.get(widget)?.radius;
|
||||
let lc = cx.doc.node(widgets.id).and_then(LiveCorners::of)?;
|
||||
let n = lc.base.anchor_count();
|
||||
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
|
||||
let base = lc.base.into_owned();
|
||||
Some(Self { widgets, widget, base, radii, kinds, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
|
||||
}
|
||||
|
||||
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
|
||||
/// corner's inward diagonal (in the shape's own units), from square to fully round; and
|
||||
/// whether the travel reached fully round (the limit every corner shares).
|
||||
/// corner's bisector (in the corners' own units), from square to fully round; and whether the
|
||||
/// travel reached fully round.
|
||||
fn radius_at(&self, p: Point) -> (f64, bool) {
|
||||
let w = &self.widgets;
|
||||
let inv = w.xf.inverse();
|
||||
let d = inv * p - inv * self.start;
|
||||
let (_, (sx, sy)) = CORNERS[self.corner];
|
||||
let max = shapes::max_corner_radius(w.w, w.h);
|
||||
let r = shapes::fitted_corner_radius(w.w, w.h, w.radii[self.corner]) + (d.x * sx + d.y * sy) / 2.0;
|
||||
let Some(w) = self.widgets.widgets.get(self.widget) else { return (self.radius, self.at_limit) };
|
||||
let inv = self.widgets.xf.inverse();
|
||||
let max = w.corner.max_radius();
|
||||
let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start);
|
||||
(r.min(max).max(0.0), max > 0.0 && r >= max)
|
||||
}
|
||||
|
||||
/// The edited corners' cuts at the dragged radius, in document coordinates.
|
||||
fn edited_corners(&self) -> BezPath {
|
||||
let w = &self.widgets;
|
||||
let rect = Rect::new(0.0, 0.0, w.w, w.h);
|
||||
let radii = std::array::from_fn(|k| if w.shown[k] { self.radius } else { w.radii[k] });
|
||||
let corner_of = shapes::rectangle_anchor_corners(rect, radii);
|
||||
let path = shapes::rectangle_with_corners(rect, radii, w.kinds).transformed(w.xf);
|
||||
/// The cuts of the edited corners that are at their largest radius, in document coordinates.
|
||||
fn corners_at_limit(&self) -> BezPath {
|
||||
let shown = &self.widgets.widgets;
|
||||
let mut radii = self.radii.clone();
|
||||
for w in shown {
|
||||
if let Some(r) = radii.get_mut(w.corner.index) {
|
||||
*r = self.radius;
|
||||
}
|
||||
}
|
||||
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
|
||||
let (path, sources) = cut_corners(&self.base, &radii, &self.kinds);
|
||||
let mut out = BezPath::new();
|
||||
let Some(sp) = path.subpaths.first() else { return out };
|
||||
// A corner's cut is the segment between its two anchors (the last one, for the top-left).
|
||||
for i in 0..sp.segment_count() {
|
||||
let (a, b) = (corner_of.get(i), corner_of.get(i + 1).or(corner_of.first()));
|
||||
if a == b && a.is_some_and(|k| w.shown.get(*k) == Some(&true)) {
|
||||
let c = sp.segment(i);
|
||||
out.move_to(c.p0);
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
for (sp, from) in path.transformed(self.widgets.xf).subpaths.iter().zip(&sources) {
|
||||
// A corner's cut is the segment between its two anchors (the last one, for a closed
|
||||
// subpath's first corner).
|
||||
for i in 0..sp.segment_count() {
|
||||
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
|
||||
if a == b && a.is_some_and(full) {
|
||||
let c = sp.segment(i);
|
||||
out.move_to(c.p0);
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -205,22 +249,21 @@ impl CornerDrag {
|
|||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began {
|
||||
vec![Action::Commit]
|
||||
} else if self.alt {
|
||||
let kind = self.widgets.kinds[self.corner].next();
|
||||
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(kind)))]
|
||||
} else if let Some(w) = self.widgets.widgets.get(self.widget).filter(|_| self.alt) {
|
||||
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius readout next to the pointer; at the largest radius, the edited corners in red.
|
||||
/// The radius readout next to the pointer; at the largest radius, the corners there in red.
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if !self.began {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }];
|
||||
if self.at_limit {
|
||||
out.push(Overlay::Path { path: self.edited_corners(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
|
||||
out.push(Overlay::Path { path: self.corners_at_limit(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -240,24 +283,38 @@ mod tests {
|
|||
|
||||
/// A live `w` × `h` rectangle with corner radius `r`.
|
||||
fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
|
||||
doc_with(live.to_path(), Some(live))
|
||||
}
|
||||
|
||||
/// A document holding one path with its live shape.
|
||||
fn doc_with(path: PathData, live: Option<LiveShape>) -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
|
||||
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
|
||||
let mut n = Node::path(id, path, Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
*slot = live;
|
||||
}
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// A five-pointed star at (200, 200) of radii 60 and 30 (#511): a plain path.
|
||||
fn star() -> (Document, NodeId) {
|
||||
doc_with(vectorcraft_geom::shapes::star(Point::new(200.0, 200.0), 60.0, 30.0, 5, 0.0), None)
|
||||
}
|
||||
|
||||
fn selected(id: NodeId) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
s
|
||||
}
|
||||
|
||||
fn pts(w: &CornerWidgets) -> Vec<Point> {
|
||||
w.visible().collect()
|
||||
}
|
||||
|
||||
fn radius(a: &Action) -> f64 {
|
||||
let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") };
|
||||
assert_eq!(c, "object.setLiveShape");
|
||||
|
|
@ -267,17 +324,19 @@ mod tests {
|
|||
#[test]
|
||||
fn widgets_sit_inside_each_corner() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.points, [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]);
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
|
||||
assert_eq!(pts(&w), [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]);
|
||||
// A larger radius moves them to the arc centres; zooming in keeps a 10 px minimum.
|
||||
let (d, id) = live_rect(20.0, Affine::translate((100.0, 100.0)));
|
||||
assert_eq!(CornerWidgets::of(&d, &selected(id), 1.0).unwrap().points[0], Point::new(120.0, 120.0));
|
||||
assert_eq!(CornerWidgets::of(&d, &selected(id), 4.0).unwrap().points[2], Point::new(180.0, 180.0));
|
||||
// Hidden when the shape is tiny on screen, for plain paths and without a single selection.
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 0.2).is_none());
|
||||
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap())[0], Point::new(120.0, 120.0));
|
||||
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 4.0, false).unwrap())[2], Point::new(180.0, 180.0));
|
||||
// Hidden when the shape is tiny on screen and without a single selection; a plain path
|
||||
// shows them with Direct Selection only.
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 0.2, true).is_none());
|
||||
let (d, id) = doc_with_rect();
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 1.0).is_none());
|
||||
assert!(CornerWidgets::of(&d, &Selection::default(), 1.0).is_none());
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 1.0, false).is_none());
|
||||
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, true).unwrap())[0], Point::new(110.0, 110.0));
|
||||
assert!(CornerWidgets::of(&d, &Selection::default(), 1.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -307,9 +366,9 @@ mod tests {
|
|||
fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() {
|
||||
let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0)));
|
||||
let s = selected(id);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
|
||||
let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)];
|
||||
assert_eq!(w.points, centres);
|
||||
assert_eq!(pts(&w), centres);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let red = |t: &dyn crate::Tool| {
|
||||
|
|
@ -339,8 +398,8 @@ mod tests {
|
|||
// 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20.
|
||||
let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0));
|
||||
let s = selected(id);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert_eq!(w.points[0], Point::new(120.0, 120.0));
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
|
||||
assert_eq!(pts(&w)[0], Point::new(120.0, 120.0));
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = crate::create("directSelection");
|
||||
|
|
@ -357,8 +416,8 @@ mod tests {
|
|||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert!(w.points[0].distance(Point::new(190.0, 110.0)) < 1e-9);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
|
||||
assert!(pts(&w)[0].distance(Point::new(190.0, 110.0)) < 1e-9);
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 120.0));
|
||||
|
|
@ -377,10 +436,10 @@ mod tests {
|
|||
s.anchors.insert(id, [(0, 2)].into());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert_eq!(w.shown, [false, false, true, false]);
|
||||
assert_eq!(w.visible().collect::<Vec<_>>(), [Point::new(190.0, 190.0)]);
|
||||
assert!(!over_widget(&cx, Point::new(110.0, 110.0)), "the other corners hide theirs");
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap();
|
||||
assert_eq!(w.corners(), [2]);
|
||||
assert_eq!(pts(&w), [Point::new(190.0, 190.0)]);
|
||||
assert!(!over_widget(&cx, Point::new(110.0, 110.0), true), "the other corners hide theirs");
|
||||
let mut t = crate::create("directSelection");
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0));
|
||||
|
|
@ -423,20 +482,20 @@ mod tests {
|
|||
#[test]
|
||||
fn corners_wider_than_the_preference_hide_their_widgets() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.within_angle(177.0).map(|w| w.shown), Some([true; 4]));
|
||||
assert_eq!(w.within_angle(90.0).map(|w| w.shown), Some([true; 4]));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
|
||||
assert_eq!(w.clone().within_angle(177.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3]));
|
||||
assert_eq!(w.clone().within_angle(90.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3]));
|
||||
assert!(w.within_angle(89.0).is_none());
|
||||
// Sheared by 30°: 60° at the top-left and bottom-right corners, 120° at the others.
|
||||
let shear = Affine::translate((100.0, 100.0)) * Affine::new([1.0, 0.0, 30f64.to_radians().tan(), 1.0, 0.0, 0.0]);
|
||||
let (d, id) = live_rect(0.0, shear);
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.within_angle(100.0).map(|w| w.shown), Some([true, false, true, false]));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
|
||||
assert_eq!(w.clone().within_angle(100.0).map(|w| w.corners()), Some(vec![0, 2]));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let c = ToolContext { corner_widget_max_angle: 100.0, ..cx(&d, &s, &p) };
|
||||
assert!(over_widget(&c, w.points[0]));
|
||||
assert!(!over_widget(&c, w.points[1]), "the 120° corner's widget is hidden");
|
||||
assert!(over_widget(&c, pts(&w)[0], false));
|
||||
assert!(!over_widget(&c, pts(&w)[1], false), "the 120° corner's widget is hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -445,14 +504,113 @@ mod tests {
|
|||
let s = selected(id);
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
assert!(over_widget(&c, Point::new(110.0, 110.0)));
|
||||
assert!(!over_widget(&c, Point::new(150.0, 150.0)));
|
||||
assert!(over_widget(&c, Point::new(110.0, 110.0), false));
|
||||
assert!(!over_widget(&c, Point::new(150.0, 150.0), false));
|
||||
c.corner_widgets = false;
|
||||
assert!(!over_widget(&c, Point::new(110.0, 110.0)));
|
||||
assert!(!over_widget(&c, Point::new(110.0, 110.0), true));
|
||||
// With the widgets hidden, a drag from the same spot moves the object.
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 110.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 110.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
}
|
||||
|
||||
/// #511: Direct Selection shows a widget in each of a star's ten corners (the Selection tool
|
||||
/// none: a star isn't a live shape); dragging a tip's inward rounds them all as one step.
|
||||
#[test]
|
||||
fn a_star_shows_widgets_in_every_corner_with_direct_selection() {
|
||||
let (d, id) = star();
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
assert!(CornerWidgets::for_tool(&cx, false).is_none());
|
||||
let w = CornerWidgets::for_tool(&cx, true).expect("widgets on a plain path");
|
||||
assert_eq!(w.corners(), (0..10).collect::<Vec<_>>());
|
||||
// The top tip's widget sits 10 √2 px down its bisector.
|
||||
let tip = pts(&w)[0];
|
||||
assert!(tip.distance(Point::new(200.0, 140.0 + 10.0 * 2f64.sqrt())) < 1e-9, "{tip:?}");
|
||||
let mut t = crate::create("directSelection");
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0));
|
||||
assert_eq!(a[0], Action::Begin("Corner Radius".into()));
|
||||
// The widget follows the arc's centre: r / sin(angle / 2) along the bisector.
|
||||
let c = vectorcraft_geom::corners::path_corners(&star_path(&d, id))[0];
|
||||
let want = 10.0 * (c.angle_deg() / 2.0).to_radians().sin();
|
||||
assert!((radius(&a[1]) - want).abs() < 1e-9, "{a:?} want {want}");
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0 && v.get("corners").is_none()), "every corner: {a:?}");
|
||||
// The Selection tool drags the star instead.
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0))[0], Action::Begin("Move".into()));
|
||||
}
|
||||
|
||||
fn star_path(d: &Document, id: NodeId) -> PathData {
|
||||
let Some(NodeKind::Path { path, .. }) = d.node(id).map(|n| &n.kind) else { panic!("not a path") };
|
||||
path.clone()
|
||||
}
|
||||
|
||||
/// #511: a Direct-Selected anchor of the star shows its corner's widget alone, and the
|
||||
/// preference hides the star's wide inner corners (about 125°) below their angle.
|
||||
#[test]
|
||||
fn star_corners_follow_the_anchors_and_the_angle_preference() {
|
||||
let (d, id) = star();
|
||||
let mut s = selected(id);
|
||||
s.anchors.insert(id, [(0, 3)].into());
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let w = CornerWidgets::for_tool(&c, true).unwrap();
|
||||
assert_eq!(w.corners(), [3]);
|
||||
let mut t = crate::create("directSelection");
|
||||
let at = pts(&w)[0];
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 20.0));
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["corners"] == json!([3])), "{a:?}");
|
||||
// Corners wider than 90° hide: the five tips keep theirs.
|
||||
let whole = selected(id);
|
||||
let c = ToolContext { corner_widget_max_angle: 90.0, ..cx(&d, &whole, &p) };
|
||||
let w = CornerWidgets::for_tool(&c, true).unwrap();
|
||||
assert_eq!(w.corners(), [0, 2, 4, 6, 8]);
|
||||
assert_eq!(w.command("radius", json!(4.0))["corners"], json!([0, 2, 4, 6, 8]));
|
||||
}
|
||||
|
||||
/// A live polygon shows its widgets to the Selection tool too; past every corner's limit the
|
||||
/// cuts outline in red.
|
||||
#[test]
|
||||
fn a_live_polygon_rounds_with_either_tool() {
|
||||
let live = LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![] };
|
||||
let (d, id) = doc_with(live.to_path(), Some(live));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
for any_path in [false, true] {
|
||||
assert_eq!(CornerWidgets::for_tool(&cx, any_path).unwrap().corners().len(), 6);
|
||||
}
|
||||
let tip = pts(&CornerWidgets::for_tool(&cx, false).unwrap())[0];
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 300.0));
|
||||
// A hexagon's 120° corners stop where their cuts meet: 40 · tan 60°.
|
||||
assert!((radius(&a[1]) - 40.0 * 3f64.sqrt()).abs() < 1e-9, "{a:?}");
|
||||
let red = t
|
||||
.overlays(&cx)
|
||||
.into_iter()
|
||||
.any(|o| matches!(o, Overlay::Path { color, path, .. } if color == crate::builder::HIGHLIGHT_RED && path.elements().len() == 12));
|
||||
assert!(red, "six arcs at the limit");
|
||||
}
|
||||
|
||||
/// A pen path's middle corner shows a widget; its ends and curved corners don't.
|
||||
#[test]
|
||||
fn a_pen_path_shows_widgets_on_its_corners_only() {
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(
|
||||
&[Point::new(100.0, 100.0), Point::new(300.0, 100.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)],
|
||||
false,
|
||||
);
|
||||
sp.anchors[2].h_out = Point::new(250.0, 350.0);
|
||||
let (d, id) = doc_with(PathData::single(sp), None);
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let w = CornerWidgets::for_tool(&cx(&d, &s, &p), true).unwrap();
|
||||
assert_eq!(w.corners(), [1]);
|
||||
assert_eq!(pts(&w), [Point::new(290.0, 110.0)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//! steps), drag a direction handle to
|
||||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||||
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
|
||||
//! deselected, the others selected), drag a live rectangle's corner widget to round its corners
|
||||
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
|
||||
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
|
||||
//! the Corners dialog).
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group;
|
||||
|
|
@ -317,13 +317,13 @@ impl Tool for DirectSelectionTool {
|
|||
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
|
||||
}
|
||||
}
|
||||
(PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p).into_iter().collect(),
|
||||
(PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p, true).into_iter().collect(),
|
||||
(PointerKind::Move, State::Idle) => {
|
||||
self.hover = if self.group || !cx.highlight_anchors { None } else { hovered_anchor(cx, p) };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
if let Some(c) = CornerDrag::hit(cx, ev) {
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, true) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
|
|
@ -597,7 +597,7 @@ impl Tool for DirectSelectionTool {
|
|||
}
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -234,7 +238,7 @@ pub struct ToolContext<'a> {
|
|||
/// points (a transform's reference point) land on anchors and ruler guides within
|
||||
/// [`Self::snap_tolerance`].
|
||||
pub snap_to_point: bool,
|
||||
/// View → Show Corner Widget: live rectangles show draggable Live Corners widgets.
|
||||
/// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners.
|
||||
pub corner_widgets: bool,
|
||||
/// Which paint proxy is in front (true = Fill): the one the Gradient tool edits.
|
||||
pub fill_active: bool,
|
||||
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
|
||||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||||
|
|
@ -169,7 +169,7 @@ impl Tool for SelectionTool {
|
|||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::DoubleClick, _) => {
|
||||
self.state = State::Idle;
|
||||
if let Some(a) = corners::double_click(cx, p).or_else(|| typewidget::double_click(cx, p)) {
|
||||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| typewidget::double_click(cx, p)) {
|
||||
return vec![a];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
|
|
@ -191,7 +191,7 @@ impl Tool for SelectionTool {
|
|||
(PointerKind::Down, _) => {
|
||||
// 1. Live Corners widgets and type on a path's brackets, then the bounding-box
|
||||
// handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev) {
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
|
|
@ -381,7 +381,7 @@ impl Tool for SelectionTool {
|
|||
if self.guide.busy() {
|
||||
return self.guide.cursor(cx, p).unwrap_or_default();
|
||||
}
|
||||
if over_widget(cx, p) {
|
||||
if over_widget(cx, p, false) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if over_bracket(cx, p) {
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -864,9 +864,14 @@ fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
|||
/// 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 [top, left, corner] = ruler_rects(full);
|
||||
// Right-click on a ruler or the origin box: the document units, to swap between them as
|
||||
// Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]).
|
||||
let mut unit_clicked = None;
|
||||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::drag());
|
||||
// Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right).
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click_and_drag());
|
||||
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
let kind = if resp.drag_stopped() {
|
||||
PointerKind::Up
|
||||
} else if resp.dragged() {
|
||||
|
|
@ -885,6 +890,11 @@ fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egu
|
|||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
let corner = ui.interact(corner, egui::Id::new("ruler-corner"), Sense::click());
|
||||
corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
if let Some((id, p)) = unit_clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
}
|
||||
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
|
||||
|
|
@ -1419,11 +1429,16 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
|||
anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct));
|
||||
}
|
||||
}
|
||||
// Live Corners widgets (Selection / Direct Selection on a single live rectangle).
|
||||
// Live Corners widgets (Selection on a live rectangle or polygon, Direct Selection on any path).
|
||||
if matches!(tool, "selection" | "directSelection")
|
||||
&& app.ui.view.corner_widgets
|
||||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
|
||||
.and_then(|w| w.within_angle(app.session.prefs.hide_corner_widget_above))
|
||||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::showing(
|
||||
&st.doc,
|
||||
&st.selection,
|
||||
xf.zoom,
|
||||
tool == "directSelection",
|
||||
app.ui.view.corner_widgets,
|
||||
app.session.prefs.hide_corner_widget_above,
|
||||
)
|
||||
{
|
||||
let color = c32(st.doc.layer_color(w.id));
|
||||
for sp in w.visible().map(|q| xf.to_screen(q)) {
|
||||
|
|
@ -2249,6 +2264,25 @@ mod tests {
|
|||
assert_eq!(widgets(&mut app), 0);
|
||||
}
|
||||
|
||||
/// #511: a star shows a widget in each of its ten corners with Direct Selection (none with
|
||||
/// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them.
|
||||
#[test]
|
||||
fn a_star_shows_corner_widgets_with_direct_selection() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.star", &json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let widgets = |app: &mut VectorcraftApp| {
|
||||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
|
||||
};
|
||||
app.select_tool("selection");
|
||||
assert_eq!(widgets(&mut app), 0);
|
||||
app.select_tool("directSelection");
|
||||
assert_eq!(widgets(&mut app), 10);
|
||||
app.run("view.cornerWidget", json!({})).unwrap();
|
||||
assert_eq!(widgets(&mut app), 0);
|
||||
}
|
||||
|
||||
/// The type widget beside selected type's bounding box: hollow (white) on point type, filled
|
||||
/// in the layer colour on area type.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -348,8 +348,7 @@ fn rasterize(c: Cursor, ppp: f32) -> Option<CustomCursorImage> {
|
|||
let (w, h) = (size.x as usize, size.y as usize);
|
||||
let mut px = vec![[0.0; 4]; w * h];
|
||||
let corner = |v: &Vertex| (v.pos.to_vec2() * ppp - min, v.color);
|
||||
for tri in mesh.indices.chunks_exact(3) {
|
||||
let &[i, j, k] = tri else { continue };
|
||||
for &[i, j, k] in mesh.indices.as_chunks::<3>().0 {
|
||||
if let (Some(a), Some(b), Some(c)) = (mesh.vertices.get(i as usize), mesh.vertices.get(j as usize), mesh.vertices.get(k as usize)) {
|
||||
fill_triangle(&mut px, w, [corner(a), corner(b), corner(c)]);
|
||||
}
|
||||
|
|
@ -463,7 +462,7 @@ mod tests {
|
|||
assert_eq!(img.rgba.len(), w * h * 4, "{c:?}");
|
||||
assert!(img.hotspot[0] < img.size[0] && img.hotspot[1] < img.size[1], "{c:?} at {ppp}: {img:?}");
|
||||
assert!(w as f32 <= 50.0 * ppp && h as f32 <= 50.0 * ppp, "{c:?} at {ppp}: {img:?}");
|
||||
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255), "{c:?}: nothing opaque");
|
||||
assert!(img.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 255), "{c:?}: nothing opaque");
|
||||
}
|
||||
}
|
||||
for c in [Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, Cursor::ZoomOut, Cursor::NotAllowed] {
|
||||
|
|
@ -482,7 +481,7 @@ mod tests {
|
|||
assert!(pixel(&img, hx, hy)[3] > 0, "{:?}", pixel(&img, hx, hy));
|
||||
let body = pixel(&img, hx + 2, hy + 8);
|
||||
assert!(body[3] == 255 && body[..3].iter().all(|&c| c < 40), "{body:?}");
|
||||
assert!(img.rgba.chunks_exact(4).any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo");
|
||||
assert!(img.rgba.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[..3].iter().all(|&c| c > 215)), "no white halo");
|
||||
// Nothing left of or above the tip but the halo.
|
||||
assert!((0..img.size[1] as usize).all(|y| pixel(&img, 0, y)[..3].iter().all(|&c| c > 100) || pixel(&img, 0, y)[3] < 128));
|
||||
// At twice the pixels per point, twice the pixels.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
//! Corners: double-clicking a Live Corners widget (or `ui.corners`) sets the corner kind and
|
||||
//! radius of a live rectangle's corners: those whose widgets show (the Direct-Selected ones, else
|
||||
//! all four). OK runs `object.setLiveShape` for them, one undo step.
|
||||
//! radius of a path's corners: those whose widgets show (the Direct-Selected ones, else every
|
||||
//! corner). OK runs `object.setLiveShape` for them, one undo step.
|
||||
//!
|
||||
//! Fields: `id`, `corners` (indices 0–3: top-left, top-right, bottom-right, bottom-left), `kind`
|
||||
//! ("round", "invertedRound" or "chamfer") and `radius` (pt). While the corners differ in kind
|
||||
//! or radius, that field is absent and OK leaves it as it is.
|
||||
//! Fields: `id`, `corners` (anchor indices of the path with its corners uncut: a rectangle's 0–3
|
||||
//! from the top-left clockwise), `kind` ("round", "invertedRound" or "chamfer") and `radius`
|
||||
//! (pt). While the corners differ in kind or radius, that field is absent and OK leaves it as it
|
||||
//! is.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::doc::{NodeId, NodeKind};
|
||||
use vectorcraft_engine::doc::{LiveCorners, NodeId};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
|
||||
use super::swatch_options::{grid, label};
|
||||
|
|
@ -20,7 +23,7 @@ pub const KIND: &str = vectorcraft_tools::corners::DIALOG;
|
|||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").into(), body, confirm, min_width: 280.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open Corners for `{id?, corners?}`: the live rectangle `id` (default: the selected one) and its
|
||||
/// Open Corners for `{id?, corners?}`: the path `id` (default: the selected one) and its
|
||||
/// `corners` (default: the selected corners), filled in with their kind and radius.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
|
|
@ -28,22 +31,21 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
|||
Some(id) => NodeId(id),
|
||||
None => match st.selection.objects[..] {
|
||||
[id] => id,
|
||||
_ => return Err("select one live rectangle, or give its `id`".into()),
|
||||
_ => return Err("select one path, or give its `id`".into()),
|
||||
},
|
||||
};
|
||||
let Some(NodeKind::Path { live: Some(live @ vectorcraft_engine::doc::LiveShape::Rectangle { .. }), .. }) = st.doc.node(id).map(|n| &n.kind)
|
||||
else {
|
||||
return Err("Corners edits a live rectangle".into());
|
||||
let Some(live) = st.doc.node(id).and_then(LiveCorners::of) else {
|
||||
return Err("Corners edits the corners of a path".into());
|
||||
};
|
||||
let corners = match p.get("corners").and_then(Value::as_array) {
|
||||
Some(a) => std::array::from_fn(|k| a.iter().any(|v| v.as_u64() == Some(k as u64))),
|
||||
None => live.picked_corners(st.selection.partial(id)),
|
||||
let corners: BTreeSet<usize> = match p.get("corners").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).filter_map(|k| usize::try_from(k).ok()).filter(|k| live.corner(*k).is_some()).collect(),
|
||||
None => live.picked(st.selection.partial(id)),
|
||||
};
|
||||
if !corners.contains(&true) {
|
||||
return Err("give corners 0–3".into());
|
||||
if corners.is_empty() {
|
||||
return Err("give corners of the path: anchors between two straight sides".into());
|
||||
}
|
||||
let (radius, kind) = live.corner_style(corners);
|
||||
let mut fields = json!({"id": id.0, "corners": (0..4).filter(|k| corners[*k]).collect::<Vec<_>>()});
|
||||
let (radius, kind) = live.style(&corners);
|
||||
let mut fields = json!({"id": id.0, "corners": corners});
|
||||
if let Some(k) = kind {
|
||||
fields["kind"] = json!(k);
|
||||
}
|
||||
|
|
@ -94,7 +96,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::doc::LiveShape;
|
||||
use vectorcraft_engine::doc::{LiveShape, NodeKind};
|
||||
use vectorcraft_tools::corners::CornerWidgets;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
|
|
@ -121,7 +123,7 @@ mod tests {
|
|||
/// The radius the Properties panel's Corner Radius field shows.
|
||||
fn panel_radius(app: &VectorcraftApp, id: u64) -> Option<f64> {
|
||||
let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone();
|
||||
crate::panels::corner_radius(app, &n, &live(app, id))
|
||||
crate::panels::corner_radius(app, &n)
|
||||
}
|
||||
|
||||
fn shown(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
|
|
@ -151,7 +153,7 @@ mod tests {
|
|||
app.select_tool("directSelection");
|
||||
let view = app.view_info();
|
||||
let st = app.session.active().unwrap();
|
||||
let at: Vec<_> = CornerWidgets::of(&st.doc, &st.selection, view.zoom).unwrap().visible().collect();
|
||||
let at: Vec<_> = CornerWidgets::of(&st.doc, &st.selection, view.zoom, true).unwrap().visible().collect();
|
||||
assert_eq!(at.len(), 1);
|
||||
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, at[0].x, at[0].y), view);
|
||||
let d = app.ui.dialog.clone().expect("Corners opened");
|
||||
|
|
@ -192,8 +194,25 @@ mod tests {
|
|||
app.ui.dialog.as_mut().unwrap().fields.insert("radius".into(), json!("3 pt"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(corners(&app, id), ([3.0; 4], before.1));
|
||||
// Only live rectangles, and only real corners.
|
||||
// Only paths, and only real corners.
|
||||
assert!(app.run("ui.corners", json!({"id": 9999})).is_err());
|
||||
assert!(app.run("ui.corners", json!({"corners": [7]})).is_err());
|
||||
let e = app.run("shape.ellipse", json!({"x": 0, "y": 0, "width": 50, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(app.run("ui.corners", json!({"id": e})).is_err());
|
||||
}
|
||||
|
||||
/// #511: a star's corners open in Corners too; OK rounds them.
|
||||
#[test]
|
||||
fn corners_edits_a_star() {
|
||||
let (mut app, _) = app();
|
||||
let id = app.run("shape.star", json!({"cx": 200, "cy": 200, "radius1": 60, "radius2": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("ui.corners", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.fields.get("corners"), d.f64("radius", -1.0)), (Some(&json!((0..10).collect::<Vec<_>>())), 0.0));
|
||||
d.fields.insert("radius".into(), json!(5));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone();
|
||||
assert!(matches!(&n.kind, NodeKind::Path { live: Some(LiveShape::Path { .. }), .. }));
|
||||
assert_eq!(crate::panels::corner_radius(&app, &n), Some(5.0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3072,7 +3072,7 @@ axis Both Ambos
|
|||
@msg Convert to Artboards: selection has no area Convertir en mesas de trabajo: la selección no tiene área
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Convertir en forma: no hay rectángulos ni elipses seleccionados
|
||||
@msg Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
|
||||
@msg Corners edits a live rectangle Esquinas edita un rectángulo interactivo
|
||||
@msg Corners edits the corners of a path Esquinas edita las esquinas de un trazado
|
||||
@msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
|
||||
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
|
||||
@msg Couldn't place {name}: the document it was dropped on was closed No se pudo colocar {name}: se cerró el documento en el que se soltó
|
||||
|
|
@ -3238,7 +3238,7 @@ axis Both Ambos
|
|||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
|
||||
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
|
||||
@msg give corners 0–3 indica esquinas de 0 a 3
|
||||
@msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos
|
||||
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
|
||||
@msg guides are locked las guías están bloqueadas
|
||||
@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió
|
||||
|
|
@ -3422,7 +3422,7 @@ axis Both Ambos
|
|||
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
|
||||
@msg select at least two objects selecciona al menos dos objetos
|
||||
@msg select objects, not layers selecciona objetos, no capas
|
||||
@msg select one live rectangle, or give its `id` selecciona un rectángulo interactivo o indica su `id`
|
||||
@msg select one path, or give its `id` selecciona un trazado o indica su `id`
|
||||
@msg select paths to join selecciona los trazados que quieres unir
|
||||
@msg select the art to repeat selecciona la ilustración que quieres repetir
|
||||
@msg select the artwork to warp selecciona la ilustración que quieres deformar
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3072,7 +3072,7 @@ axis Both Entrambi
|
|||
@msg Convert to Artboards: selection has no area Converti in tavole da disegno: la selezione non ha area
|
||||
@msg Convert to Shape: no rectangles or ellipses selected Converti in forma: nessun rettangolo o ellisse selezionato
|
||||
@msg Copied the CSS of {rules} object(s) Copiato il CSS di {rules} oggetto/i
|
||||
@msg Corners edits a live rectangle Angoli modifica un rettangolo dinamico
|
||||
@msg Corners edits the corners of a path Angoli modifica gli angoli di un tracciato
|
||||
@msg Couldn't copy to the system clipboard: {e} Impossibile copiare negli appunti di sistema: {e}
|
||||
@msg Couldn't open {name}: {e} Impossibile aprire {name}: {e}
|
||||
@msg Couldn't place {name}: the document it was dropped on was closed Impossibile inserire {name}: il documento su cui è stato rilasciato è stato chiuso
|
||||
|
|
@ -3238,7 +3238,7 @@ axis Both Entrambi
|
|||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versione del file {0} è più recente di quelle supportate da questo VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` il punto a forma libera {i} richiede `at` [x, y] e un `color` valido
|
||||
@msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due)
|
||||
@msg give corners 0–3 specifica angoli da 0 a 3
|
||||
@msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti
|
||||
@msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza
|
||||
@msg guides are locked le guide sono bloccate
|
||||
@msg image '{key}' has no pixels and was left out l'immagine '{key}' non ha pixel ed è stata omessa
|
||||
|
|
@ -3422,7 +3422,7 @@ axis Both Entrambi
|
|||
@msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare
|
||||
@msg select at least two objects seleziona almeno due oggetti
|
||||
@msg select objects, not layers seleziona oggetti, non livelli
|
||||
@msg select one live rectangle, or give its `id` seleziona un rettangolo dinamico o specifica il suo `id`
|
||||
@msg select one path, or give its `id` seleziona un tracciato o specifica il suo `id`
|
||||
@msg select paths to join seleziona i tracciati da unire
|
||||
@msg select the art to repeat seleziona la grafica da ripetere
|
||||
@msg select the artwork to warp seleziona la grafica da alterare
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -171,6 +171,29 @@ fn partial_catalogs_only_translate_strings_the_ui_shows() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The engine learns the language the UI is drawn in each frame: new type takes the Japanese
|
||||
/// defaults while it is Japanese (#432). The frames stay in English: the drawing language is
|
||||
/// process-wide, and a Japanese frame would translate the tests running beside this one.
|
||||
#[test]
|
||||
fn the_engine_follows_the_interface_language() {
|
||||
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::VectorcraftApp| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.ui_language.as_deref(), Some("en"), "tests resolve `auto` to English");
|
||||
assert!(!app.session.japanese_interface());
|
||||
// Each frame hands the engine the language it draws in, whatever the engine had.
|
||||
app.session.ui_language = Some("ja".into());
|
||||
assert!(app.session.japanese_interface());
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.ui_language.as_deref(), Some("en"));
|
||||
// The language a frame would draw in follows the preference.
|
||||
app.session.prefs.interface_language = "ja".into();
|
||||
assert_eq!(app.ui_language().code(), "ja");
|
||||
}
|
||||
|
||||
/// VectorCraft › Language (`app.language`) sets the `interfaceLanguage` preference, which is what
|
||||
/// persists; the checked item follows the preference, and a bad code is an error, not a change.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -726,7 +726,12 @@ impl VectorcraftApp {
|
|||
}
|
||||
|
||||
fn logic_frame(&mut self, ctx: &egui::Context) {
|
||||
i18n::set_current(self.ui_language());
|
||||
let lang = self.ui_language();
|
||||
i18n::set_current(lang);
|
||||
// The engine gives new type the Japanese defaults while the UI is in Japanese.
|
||||
if self.session.ui_language.as_deref() != Some(lang.code()) {
|
||||
self.session.ui_language = Some(lang.code().to_string());
|
||||
}
|
||||
self.adopt_context(ctx);
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
|
|
|
|||
|
|
@ -485,7 +485,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"ui.corners",
|
||||
"Corners…",
|
||||
"",
|
||||
"{id?, corners?: [0..3…]} open Corners for live rectangle `id` (default: the selected one) and its corners (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else all four) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
|
||||
"{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
|
||||
),
|
||||
(
|
||||
"ui.colorGuideLimit",
|
||||
|
|
@ -1414,6 +1414,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
|||
let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk);
|
||||
p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" })
|
||||
}
|
||||
// The ruler context menu lists the document units, the current one checked.
|
||||
"document.setUnits" => {
|
||||
p.get("units").and_then(Value::as_str).and_then(vectorcraft_engine::doc::Unit::named) == Some(app.session.general_unit())
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -2638,6 +2642,21 @@ pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut
|
|||
});
|
||||
}
|
||||
|
||||
/// The document units a right-click on a ruler offers: each runs `document.setUnits`, exactly as
|
||||
/// Preferences ▸ Units ▸ General does, with the current one checked ([`checked`]).
|
||||
pub fn ruler_unit_items() -> Vec<Item> {
|
||||
vectorcraft_engine::doc::Unit::ALL.iter().map(|u| cp(u.label(), "document.setUnits", json!({ "units": u.label() }))).collect()
|
||||
}
|
||||
|
||||
/// The ruler context menu's popup (right-click a ruler or the origin box); the unit clicked goes in
|
||||
/// `clicked`, for [`invoke`].
|
||||
pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
ui.set_min_width(160.0);
|
||||
render_items(app, ui, &ruler_unit_items(), true, clicked);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the menu bar.
|
||||
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
|
||||
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ pub mod transparency;
|
|||
use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{LiveShape, Node, StrokeLayer};
|
||||
use vectorcraft_doc::{LiveCorners, Node, StrokeLayer};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
|
|
@ -57,11 +57,24 @@ pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
|
|||
first_node(app).cloned()
|
||||
}
|
||||
|
||||
/// The radius the Live Corners of live rectangle `n` show in the panels: that of the corners the
|
||||
/// panels set (the Direct-Selected ones, else all four), blank when they differ.
|
||||
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node, live: &LiveShape) -> Option<f64> {
|
||||
/// The radius the Live Corners of path `n` show in the panels: that of the corners the panels set
|
||||
/// (the Direct-Selected ones, else every corner), blank when they differ.
|
||||
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node) -> Option<f64> {
|
||||
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
|
||||
live.corner_style(live.picked_corners(partial)).0
|
||||
let corners = LiveCorners::of(n)?;
|
||||
corners.style(&corners.picked(partial)).0
|
||||
}
|
||||
|
||||
/// The Corner Radius field of a live rectangle's or polygon's properties: [`corner_radius`],
|
||||
/// which a new value sets on those corners.
|
||||
pub(crate) fn corner_radius_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str) {
|
||||
let units = app.session.general_unit();
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, 80.0) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of selected objects.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeKind, Unit};
|
||||
use vectorcraft_doc::{LiveShape, NodeKind, Unit};
|
||||
|
||||
use super::{corner_radius, first_selected, pstate, set_pstate};
|
||||
use super::{corner_radius_row, first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, dim_label, divider, section_header};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -93,7 +93,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
divider(ui);
|
||||
section_header(ui, tl!("Quick Actions"));
|
||||
let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. }));
|
||||
let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(_), .. }));
|
||||
let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(l, LiveShape::Path { .. }));
|
||||
let mut actions: Vec<(&str, &str)> = vec![];
|
||||
if n_sel > 1 {
|
||||
actions.push((tl!("Group"), "object.group"));
|
||||
|
|
@ -335,14 +335,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
|
||||
{
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => {
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = widgets::num_field(ui, "radius", corner_radius(app, &n, live), units, 80.0) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Sides:"));
|
||||
|
|
@ -350,6 +343,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
|
||||
}
|
||||
});
|
||||
corner_radius_row(app, ui, &n, "radius");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use serde_json::json;
|
|||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::{corner_radius, first_selected, pstate, set_pstate};
|
||||
use super::{corner_radius_row, first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -166,20 +166,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok();
|
||||
}
|
||||
});
|
||||
// Live shape properties.
|
||||
// Live shape properties (a path with live corners is no shape).
|
||||
if let Some(n) = first_selected(app)
|
||||
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
|
||||
&& !matches!(live, vectorcraft_doc::LiveShape::Path { .. })
|
||||
{
|
||||
widgets::divider(ui);
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => {
|
||||
widgets::subheader(ui, tl!("Rectangle Properties:"));
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = widgets::num_field(ui, "xfp-radius", corner_radius(app, &n, live), units, 80.0) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
corner_radius_row(app, ui, &n, "xfp-radius");
|
||||
}
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
widgets::subheader(ui, tl!("Polygon Properties:"));
|
||||
|
|
@ -189,6 +185,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
app.run("object.setLiveShape", json!({"sides": s.clamp(3.0, 20.0) as u32})).ok();
|
||||
}
|
||||
});
|
||||
corner_radius_row(app, ui, &n, "xfp-radius");
|
||||
}
|
||||
_ => {
|
||||
widgets::subheader(ui, tl!("Shape Properties:"));
|
||||
|
|
|
|||
|
|
@ -110,6 +110,32 @@ fn right_click_on_empty_canvas_offers_the_view_and_a_dismissing_click_does_nothi
|
|||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
|
||||
}
|
||||
|
||||
/// #528: right-clicking a ruler offers the document units, the current one checked, and choosing one
|
||||
/// swaps them — the same effect as Preferences ▸ Units ▸ General (`document.setUnits`).
|
||||
#[test]
|
||||
fn right_clicking_a_ruler_swaps_the_document_units() {
|
||||
use vectorcraft_doc::Unit;
|
||||
let mut app = app();
|
||||
app.ui.view.rulers = true;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.session.general_unit(), Unit::Points, "a new document starts in the prefs unit");
|
||||
// Right-click on the top ruler (the band along the top, past the origin box).
|
||||
let texts = click(&mut app, &ctx, pos2(400.0, 8.0), PointerButton::Secondary);
|
||||
for label in ["Points", "Picas", "Inches", "Millimeters", "Feet & Inches", "Pixels"] {
|
||||
assert!(has(&texts, label), "{label} in {texts:?}");
|
||||
}
|
||||
assert!(texts.iter().any(|(t, _)| t == "✓ Points"), "the current unit is checked: {texts:?}");
|
||||
// Choosing a unit runs document.setUnits and closes the menu.
|
||||
let mm = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Millimeters").map(|(_, r)| r.center()).expect("Millimeters");
|
||||
let texts = click(&mut app, &ctx, mm, PointerButton::Primary);
|
||||
assert_eq!(app.session.general_unit(), Unit::Millimeters, "the ruler's unit changed");
|
||||
assert!(!has(&texts, "Inches"), "the menu closed");
|
||||
// The left ruler offers the same menu, now checking the new unit.
|
||||
let texts = click(&mut app, &ctx, pos2(8.0, 300.0), PointerButton::Secondary);
|
||||
assert!(texts.iter().any(|(t, _)| t == "✓ Millimeters"), "the left ruler reflects the current unit: {texts:?}");
|
||||
}
|
||||
|
||||
fn labels(items: &[Item]) -> Vec<&'static str> {
|
||||
items
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -291,9 +291,9 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt
|
|||
value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`.
|
||||
|
||||
Corners: double-clicking a Live Corners widget with the Selection or Direct Selection tool, or `ui.corners {id?,
|
||||
corners?}`, opens the `corners` dialog for a live rectangle's corners (the Direct-Selected ones, else all four; fields
|
||||
`id`, `corners`: indices 0–3 clockwise from the top-left, `kind`: round, invertedRound or chamfer, `radius` in points;
|
||||
`kind` or `radius` is absent while the corners differ). `ui.dialog.confirm` runs `object.setLiveShape` with them.
|
||||
corners?}`, opens the `corners` dialog for a path's corners (the Direct-Selected ones, else every corner; fields `id`,
|
||||
`corners`: anchor indices of the path with its corners uncut, a rectangle's 0–3 clockwise from the top-left, `kind`:
|
||||
round, invertedRound or chamfer, `radius` in points; `kind` or `radius` is absent while the corners differ). `ui.dialog.confirm` runs `object.setLiveShape` with them.
|
||||
|
||||
Perspective plane options: double-clicking a plane widget of the perspective grid, or `ui.perspectivePlane {plane}`,
|
||||
opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's
|
||||
|
|
|
|||
76
docs/mcp.md
76
docs/mcp.md
|
|
@ -1135,21 +1135,32 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
|
|||
|
||||
## Live Corners
|
||||
|
||||
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of live rectangles (one undo step):
|
||||
`radius` (pt) and `kind` (`round`, `invertedRound` or `chamfer`) go to the `corners` given (0 top-left, 1 top-right,
|
||||
2 bottom-right, 3 bottom-left), else to the corners holding a Direct-Selected anchor (`select.anchors`), else to all
|
||||
four. Each corner keeps its own radius and kind (the shape's `live` in queries has `radii` and, when a corner isn't
|
||||
round, `kinds`); a corner with no radius is one anchor, a cut one two, and Direct-Selected corners stay selected as
|
||||
that changes. Every corner is a circular arc, whatever the rectangle's proportions: a radius past half the shorter
|
||||
side draws at half of it (the same limit for every corner), and a rectangle from a file that kept an uneven scale in its
|
||||
transform (elliptical corners) gets circular ones in document units when its corners are next set. With the Selection or
|
||||
Direct Selection tool, dragging a corner widget rounds the corners whose widgets show (all four, or the Direct-Selected
|
||||
ones), outlining them in red once they reach that limit; Alt-clicking one cycles their kind and double-clicking one
|
||||
opens Corners (`ui.corners {id?, corners?}`, dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
|
||||
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of any path (one undo step): a live
|
||||
rectangle's or polygon's, a star's, a pen path's. A corner is an anchor without handles between two straight sides
|
||||
(not an open path's ends, not a smooth anchor, not one the sides run straight on through). `radius` (pt) and `kind`
|
||||
(`round`, `invertedRound` or `chamfer`) go to the `corners` given, else to the corners holding a Direct-Selected
|
||||
anchor (`select.anchors`), else to every corner. `corners` are anchor indices of the path with its corners uncut,
|
||||
counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a
|
||||
polygon's from its first vertex clockwise; a star's from its first tip; an index past the last anchor is an error.
|
||||
Each corner keeps its own radius and kind (the shape's `live` in queries has `radii` and, when a corner isn't round,
|
||||
`kinds`); a corner with no radius is one anchor, a cut one two, and Direct-Selected corners stay selected as that
|
||||
changes. A path that isn't a live shape keeps its uncut outline (`live` `{"shape": "path", "base", "radii"}`) so its
|
||||
corners stay editable, and is a plain path again once no corner is cut; a polygon stays a live polygon, its corners
|
||||
keeping the radius they shared when `sides` changes. Every corner is a circular arc tangent to both sides: a radius
|
||||
draws no larger than takes the cut halfway along the corner's shorter side (half the shorter side of a rectangle, the
|
||||
same limit for its four corners), the corners stay circular through uneven scales (the radius scales by the mean scale,
|
||||
or keeps its size with Scale Corners off), and a rectangle from a file that kept an uneven scale in its transform
|
||||
(elliptical corners) gets circular ones in document units when its corners are next set. Dragging a corner widget
|
||||
rounds the corners whose widgets show (every corner, or the Direct-Selected ones), outlining in red those that reach
|
||||
their limit; the Selection tool shows the widgets of live rectangles and polygons, the Direct Selection tool those of
|
||||
any path. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
|
||||
dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[1],"radius":16}}}
|
||||
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[0,3],"radius":8,"kind":"chamfer"}}}
|
||||
{"name":"run_command","arguments":{"command":"shape.star","params":{"cx":200,"cy":200,"radius1":60,"radius2":30}}}
|
||||
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"radius":5}}}
|
||||
```
|
||||
|
||||
## Use Preview Bounds
|
||||
|
|
@ -1215,8 +1226,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 +1242,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 +1258,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
|
||||
|
||||
|
|
@ -2179,6 +2214,19 @@ undo step.
|
|||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"burasagari":"forced"}}}
|
||||
```
|
||||
|
||||
## New type in a Japanese interface
|
||||
|
||||
While the interface is in Japanese (VectorCraft › Language, or `auto` on a Japanese system), new type starts with em
|
||||
box top-to-top leading (`leadingModel: "emBoxTop"`) and em box centre character alignment (`charAlign: "emBoxCenter"`);
|
||||
in other languages it starts on the Roman baseline (#432). This covers the Type tools,
|
||||
`text.create` and `text.createInPath`, which also take `leadingModel` and `charAlign` to choose, and `paraStyle.new` /
|
||||
`charStyle.new` made with no `attrs` and no text selected. The values used are kept in the journal, so a replay sets the
|
||||
same in any language. Saved documents keep what they say, and imported text (PDF, SVG, DXF…) keeps the Roman baseline.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.create","params":{"x":40,"y":60,"text":"雅楽","leadingModel":"emBoxTop","charAlign":"emBoxCenter"}}}
|
||||
```
|
||||
|
||||
## Moving and flipping type on a path
|
||||
|
||||
Type on a path flows between a start and an end bracket, stored as fractions of its path's length.
|
||||
|
|
|
|||
Loading…
Reference in New Issue