G12: Constrain Angle for the arrow keys, Shift drags and new objects (#394)

Squashed from #3363 onto main.
This commit is contained in:
Angelo Bernardini
2026-10-23 08:54:57 +07:00
committed by bflatastic
parent 960fee6bb3
commit ef1fa1d228
23 changed files with 996 additions and 77 deletions
+125 -21
View File
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rectangular Grid",
[],
None,
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}",
has_doc,
rect_grid
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Polar Grid",
[],
None,
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}",
has_doc,
polar_grid
),
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -96,11 +96,6 @@ pub(crate) fn add_art(s: &mut Session, label: &str, kind: NodeKind, name: Option
add_look(s, label, kind, look, name)
}
/// [`add_look`] with just `appearance`.
pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: Appearance, name: Option<String>) -> Result<Value> {
add_look(s, label, kind, NewArt::plain(appearance), name)
}
/// Insert a new object looking like `look` (drawing modes apply); select it.
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
insert_look(s, label, kind, look, name, true)
@@ -109,6 +104,47 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
/// [`add_look`] that selects the new object only when `select` is set; otherwise the selection
/// stays as it was.
pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>, select: bool) -> Result<Value> {
insert_look_turned(s, label, kind, look, name, select, 0.0)
}
/// [`add_art`] for an object made turned by a `rotation` param (degrees, clockwise on the page):
/// its bounding box turns with it, as after the Rotate tool.
fn add_art_turned(s: &mut Session, label: &str, kind: NodeKind, rotation: f64) -> Result<Value> {
let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
insert_look_turned(s, label, kind, look, None, true, bbox_turn(rotation))
}
/// The bounding-box angle (counter-clockwise, as `Node::bbox_angle` and the Rotate tool keep it)
/// of an object made with the clockwise `rotation` param: 0 exactly for none, a whole turn or a
/// non-finite angle, so such objects are as they were.
fn bbox_turn(rotation: f64) -> f64 {
if !rotation.is_finite() {
return 0.0;
}
let a = vectorcraft_geom::normalize_deg(-rotation);
if a == 0.0 { 0.0 } else { a }
}
/// A polygon's or star's `rotation` when it is the base orientation turned by General › Constrain
/// Angle (a Shift-drawn or click-made one), so its bounding box turns with it; else 0: a polygon
/// turned by the drag's direction keeps its box on the page's axes, as before.
fn constrained_turn(s: &Session, rotation: f64) -> f64 {
match vectorcraft_geom::constrain_rotation(s.prefs.constrain_angle) {
Some(r) if rotation.is_finite() && vectorcraft_geom::normalize_deg(r - rotation).abs() < 1e-9 => rotation,
_ => 0.0,
}
}
/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`.
fn insert_look_turned(
s: &mut Session,
label: &str,
kind: NodeKind,
look: NewArt,
name: Option<String>,
select: bool,
bbox_angle: f64,
) -> Result<Value> {
let parent = s.doc()?.target_parent()?;
let mode = s.draw_mode;
let inside = s.draw_inside;
@@ -118,6 +154,7 @@ pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: Ne
let mut n = Node::new(id, kind);
look.apply(d, &mut n);
n.name = name;
n.bbox_angle = bbox_angle;
match (mode, inside) {
(crate::DrawMode::Inside, Some(target)) if d.node(target).is_some() => {
// Wrap the target once in a clip group [mask copy, target, …new art].
@@ -192,6 +229,35 @@ fn path_kind(path: PathData, live: Option<LiveShape>) -> NodeKind {
NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false }
}
/// A click-to-size shape dialog's command `cmd` with `params` drawn along General › Constrain
/// Angle, as the reference app draws new objects along it
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>):
/// a box (rectangle, ellipse, grid) runs along the turned axes from `pivot`, the point the dialog
/// placed at the click (its top-left corner `x`, `y`, or a grid's reference point), which stays
/// put; a polygon or star turns its base orientation about its centre. The params as they are at
/// 0° or a whole turn, for other commands and when they already carry a `rotation`.
pub fn constrain_click(s: &Session, cmd: &str, mut params: Value, pivot: Point) -> Value {
let base = s.prefs.constrain_angle;
let (Some(rotation), Some(axes)) = (vectorcraft_geom::constrain_rotation(base), vectorcraft_geom::constrain_axes(base)) else { return params };
let Some(o) = params.as_object_mut().filter(|o| !o.contains_key("rotation")) else { return params };
match cmd {
"shape.rectangle" | "shape.ellipse" | "shape.rectangularGrid" | "shape.polarGrid" => {
let num = |k: &str| o.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
let (Some(x), Some(y), Some(w), Some(h)) = (num("x"), num("y"), num("width"), num("height")) else { return params };
// The box's centre along the turned axes from the pivot; the command turns it there.
let c = pivot + (axes * Point::new(x + w / 2.0 - pivot.x, y + h / 2.0 - pivot.y)).to_vec2();
o.insert("x".into(), json!(c.x - w / 2.0));
o.insert("y".into(), json!(c.y - h / 2.0));
o.insert("rotation".into(), json!(rotation));
}
"shape.polygon" | "shape.star" => {
o.insert("rotation".into(), json!(rotation));
}
_ => {}
}
params
}
fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
let x = f64_req(p, "x", cmd)?;
let y = f64_req(p, "y", cmd)?;
@@ -211,7 +277,8 @@ fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
if rotation == 0.0 {
return Ok(Affine::translate(r.origin().to_vec2()));
}
Ok(Affine::translate(r.center().to_vec2()) * Affine::rotate(rotation.to_radians()) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0)))
// Exact at quarter turns (a Constrain Angle of 90°).
Ok(Affine::translate(r.center().to_vec2()) * vectorcraft_geom::rotate_deg(rotation) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0)))
}
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
@@ -227,13 +294,13 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
xf: rect_transform(r, p, "shape.rectangle")?,
};
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
add_art_turned(s, label, path_kind(live.to_path(), Some(live)), f64_or(p, "rotation", 0.0))
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.ellipse")?;
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: rect_transform(r, p, "shape.ellipse")? };
add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None)
add_art_turned(s, "Ellipse", path_kind(live.to_path(), Some(live)), f64_or(p, "rotation", 0.0))
}
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
@@ -242,9 +309,9 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["sides", "rotation"], "shape.polygon")?;
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 xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
let xf = Affine::translate(c.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false };
add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None)
add_art_turned(s, "Polygon", path_kind(live.to_path(), Some(live)), constrained_turn(s, rot))
}
fn star(s: &mut Session, p: &Value) -> Result<Value> {
@@ -253,8 +320,15 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?;
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
add_art(s, "Star", path_kind(path, None), None)
let rot = f64_or(p, "rotation", 0.0);
let turn = vectorcraft_geom::rotate_deg(rot);
// A quarter or whole turn (a Constrain Angle of 90°, say) turns the upright star exactly.
let path = if rot != 0.0 && turn.as_coeffs()[..4].iter().all(|c| c.fract() == 0.0) {
shapes::star(Point::ZERO, r1, r2, n, 0.0).transformed(Affine::translate(c.to_vec2()) * turn)
} else {
shapes::star(c, r1, r2, n, rot)
};
add_art_turned(s, "Star", path_kind(path, None), constrained_turn(s, rot))
}
/// Flare tool: a lens flare built from radial-gradient paths in Screen mode — a bright centre, a halo
@@ -432,8 +506,9 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
/// current fill when `fill`; a path of several subpaths becomes a compound path of them. The group
/// and its members get the bounding-box angle `bbox_angle` (a grid drawn along a Constrain Angle).
fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec<PathData>, f64), fill: bool) -> Result<Value> {
let parent = s.doc()?.target_parent()?;
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
@@ -450,11 +525,14 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) ->
Node::path(d.alloc_id(), pd, Appearance::default())
};
look.apply(d, &mut n);
n.bbox_angle = bbox_angle;
std::sync::Arc::new(n)
})
.collect();
let gid = d.alloc_id();
d.insert(parent, usize::MAX, Node::group(gid, children))?;
let mut g = Node::group(gid, children);
g.bbox_angle = bbox_angle;
d.insert(parent, usize::MAX, g)?;
sel.set([gid]);
Ok(gid)
})?;
@@ -487,7 +565,7 @@ fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, CMD)?;
let paths =
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
grid_group(s, "Rectangular Grid", turned_grid(p, r, paths, CMD)?, switch(p, "fill", false, CMD)?)
}
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
@@ -499,7 +577,24 @@ fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
dividers(p, "radial", "radialSkew", CMD)?,
switch(p, "compound", false, CMD)?,
);
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
grid_group(s, "Polar Grid", turned_grid(p, r, paths, CMD)?, switch(p, "fill", false, CMD)?)
}
/// A grid's paths in `r` turned by the `rotation` param about the centre of `r` (a grid drawn
/// along a Constrain Angle), and their bounding-box angle; as they are without it or at 0.
fn turned_grid(p: &Value, r: Rect, mut paths: Vec<PathData>, cmd: &str) -> Result<(Vec<PathData>, f64)> {
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", cmd)? } else { 0.0 };
if !rotation.is_finite() {
return Err(bad(cmd, "rotation must be finite"));
}
if rotation != 0.0 {
let c = r.center().to_vec2();
let m = Affine::translate(c) * vectorcraft_geom::rotate_deg(rotation) * Affine::translate(-c);
for path in &mut paths {
path.transform(m);
}
}
Ok((paths, bbox_turn(rotation)))
}
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
@@ -561,6 +656,15 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
}
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
// Turned about the point (the first baseline's start, an area's top-left corner): type set
// along a Constrain Angle.
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 };
if !rotation.is_finite() {
return Err(bad("text.create", "rotation must be finite"));
}
if rotation != 0.0 {
t.xf *= vectorcraft_geom::rotate_deg(rotation);
}
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)?;
@@ -577,7 +681,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
// Lays the text out (and sizes an Auto Size frame).
super::typecmd::refresh_bounds(&mut t);
}
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
let made = insert_look_turned(s, "Type", NodeKind::Text(Box::new(t)), NewArt::plain(Appearance::default()), None, true, bbox_turn(rotation))?;
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
let made = super::typecmd::with_bounds(s, id, made);
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
+1 -1
View File
@@ -9,7 +9,7 @@ pub mod clipboard;
mod colorcmds;
pub mod colormgmt;
pub mod colortheme;
mod create;
pub(crate) mod create;
mod css;
mod cut;
pub(crate) mod dimension;
+19 -5
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Nudge",
[],
None,
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, along the x and y axes turned by the constrainAngle preference; else ruler guides, along the page axes)",
has_selection_or_guides,
nudge
),
@@ -527,13 +527,27 @@ fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
}
/// The arrow keys' step `(dx, dy)` (page axes, y down) along the x and y axes turned by General ›
/// Constrain Angle (`deg`, counter-clockwise on the page): with 30°, → moves 30° up from the
/// horizontal, as the reference app's arrow keys follow the constrain angle (users set it to −90°
/// to nudge along a view rotated by 90°:
/// <https://community.adobe.com/t5/illustrator-discussions/arrow-keys-and-ruler-guides-not-adjusted-when-using-rotate-view/m-p/12489564>).
pub(crate) fn along_constrain_angle(dx: f64, dy: f64, deg: f64) -> (f64, f64) {
// Whole turns are the page's axes, exactly; quarter turns are exact too.
let Some(axes) = vectorcraft_geom::constrain_axes(deg) else { return (dx, dy) };
let v = axes * Point::new(dx, dy);
(v.x, v.y)
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.nudge")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?;
check_switches(p, &["big"], "object.nudge")?;
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
let (dx, dy) = (f64_or(p, "dx", 0.0) * k, f64_or(p, "dy", 0.0) * k);
// Guides keep to the page's axes; art and anchors move along the constrain angle's.
let (gx, gy) = (dx, dy);
let (dx, dy) = along_constrain_angle(dx, dy, s.prefs.constrain_angle);
if !s.doc()?.selection.anchors.is_empty() {
// When the anchors move by 0, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 {
@@ -544,10 +558,10 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.selection.is_empty() {
let copy = bool_or(p, "copy", false);
// When guides move by 0 without a copy, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 && !copy {
if gx == 0.0 && gy == 0.0 && !copy {
return ok();
}
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
return super::docmenu::guide_move(s, &json!({ "dx": gx, "dy": gy, "copy": copy }));
}
let ids = selected_roots_to_transform(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true)
+5
View File
@@ -28,6 +28,7 @@ use vectorcraft_tools::{PaintDefaults, Tool};
pub use cmd::EyedropperOptions;
pub use cmd::clipboard::Clipboard;
pub use cmd::create::constrain_click;
pub use cmd::distortcmds::perspective_click;
pub use cmd::params::{check_keys, check_params};
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
@@ -2332,6 +2333,10 @@ mod tests_colorthemes;
#[cfg(test)]
mod tests_compoundpaint;
#[cfg(test)]
mod tests_constrain_new;
#[cfg(test)]
mod tests_constrain_tools;
#[cfg(test)]
mod tests_containers;
#[cfg(test)]
mod tests_css;
+392
View File
@@ -0,0 +1,392 @@
//! General › Constrain Angle sets the angle new objects are drawn at (G12, #394, second batch):
//! type, grids, polygons and stars at their base orientation, shapes made by a click through the
//! size dialogs, and the Line Segment tool's Shift steps. 0° and whole turns draw exactly as
//! before; quarter turns are exact.
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, NodeKind, TextKind};
use vectorcraft_geom::{PathData, Point, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
use PointerKind::{Down, Drag, Up};
fn new_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn set_angle(s: &mut Session, deg: f64) {
s.execute("prefs.set", &json!({"key": "constrainAngle", "value": deg})).unwrap();
}
fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) {
for (k, x, y) in events {
s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), ViewInfo { smart_guides: false, ..ViewInfo::default() }).unwrap();
}
}
fn last_id(s: &Session) -> NodeId {
*s.doc().unwrap().selection.objects.first().unwrap()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// The angles (degrees, counter-clockwise on the page, in 0..90) of the straight segments of `p`.
fn segment_angles(p: &PathData) -> Vec<f64> {
p.subpaths
.iter()
.flat_map(|sp| sp.anchors.windows(2).map(|w| w[1].p - w[0].p))
.filter(|v| v.hypot() > 1e-9)
.map(|v| (-v.y).atan2(v.x).to_degrees().rem_euclid(90.0))
.collect()
}
/// The paths of the group `id` (a grid), on the page.
fn grid_paths(s: &Session, id: NodeId) -> Vec<PathData> {
let n = s.doc().unwrap().doc.node(id).unwrap();
n.children()
.unwrap()
.iter()
.map(|c| match &c.kind {
NodeKind::Path { path, .. } => path.clone(),
other => panic!("a path: {other:?}"),
})
.collect()
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
/// Rectangular and polar grids are drawn along the turned axes: at 30° the frame's sides run at
/// 30° and 120°, at 90° the grid covers the drag exactly, 20 wide and 60 high along the turned
/// axes; at 0° and a whole turn the grid is the same as before, bit for bit.
#[test]
fn constrain_angle_draws_grids_along_the_turned_axes() {
let mut s = new_doc();
let draw = |s: &mut Session, tool: &str, deg: f64| {
set_angle(s, deg);
s.select_tool(tool, ViewInfo::default()).unwrap();
gesture(s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]);
last_id(s)
};
for tool in ["rectangularGrid", "polarGrid"] {
let flat = draw(&mut s, tool, 0.0);
let flat_paths = grid_paths(&s, flat);
for turn in [360.0, -360.0] {
let id = draw(&mut s, tool, turn);
assert_eq!(grid_paths(&s, id), flat_paths, "{tool} at {turn}°: as at 0°");
}
let b = bounds(&s, flat);
assert_eq!((b.x0, b.y0, b.x1, b.y1), (100.0, 100.0, 160.0, 120.0), "{tool} at 0°");
// A quarter turn: the drag's box exactly, no floating-point noise.
let id = draw(&mut s, tool, 90.0);
let b = bounds(&s, id);
assert_eq!((b.x0, b.y0, b.x1, b.y1), (100.0, 100.0, 160.0, 120.0), "{tool} at 90°: {b:?}");
}
// At 30° the rectangular grid's lines run along the turned axes.
let id = draw(&mut s, "rectangularGrid", 30.0);
for p in grid_paths(&s, id) {
for a in segment_angles(&p) {
assert!(close(a, 30.0), "a grid line at {a}°");
}
}
// Through the command: `rotation` turns a grid about its centre.
let id = NodeId(
s.execute("shape.rectangularGrid", &json!({"x": 0, "y": 0, "width": 40, "height": 20, "rotation": -90})).unwrap()["id"].as_u64().unwrap(),
);
let b = bounds(&s, id);
assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, -10.0, 30.0, 30.0));
}
/// Shift keeps a polygon or star at its base orientation, which is upright turned by the
/// Constrain Angle: at 30° the polygon is turned 30° counter-clockwise, at 90° the star's first
/// point is exactly left of its centre, at 0° and whole turns both are upright as before.
#[test]
fn constrain_angle_turns_the_base_orientation_of_polygons_and_stars() {
let mut s = new_doc();
let shift = Mods { shift: true, ..Mods::default() };
let draw = |s: &mut Session, tool: &str, deg: f64| {
set_angle(s, deg);
s.select_tool(tool, ViewInfo::default()).unwrap();
gesture(s, shift, &[(Down, 100.0, 100.0), (Drag, 130.0, 140.0), (Drag, 130.0, 140.0), (Up, 130.0, 140.0)]);
last_id(s)
};
let polygon_turn = |s: &Session, id: NodeId| {
let NodeKind::Path { live: Some(live), .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a polygon") };
let LiveShape::Polygon { xf, .. } = &**live else { panic!("a polygon") };
let [a, b, ..] = xf.as_coeffs();
-b.atan2(a).to_degrees()
};
let path = |s: &Session, id: NodeId| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { path, .. } => path.clone(),
other => panic!("a path: {other:?}"),
};
let flat = draw(&mut s, "polygon", 0.0);
assert_eq!(polygon_turn(&s, flat), 0.0);
let flat_star = draw(&mut s, "star", 0.0);
for turn in [360.0, -360.0] {
let id = draw(&mut s, "polygon", turn);
assert_eq!(path(&s, id), path(&s, flat), "polygon at {turn}°");
let id = draw(&mut s, "star", turn);
assert_eq!(path(&s, id), path(&s, flat_star), "star at {turn}°");
}
let id = draw(&mut s, "polygon", 30.0);
assert!(close(polygon_turn(&s, id), 30.0), "{}", polygon_turn(&s, id));
let id = draw(&mut s, "polygon", 90.0);
assert!(close(polygon_turn(&s, id), 90.0));
// The 50 pt star's first point: straight up at 0°, straight left at 90°, exactly.
assert_eq!(path(&s, flat_star).subpaths[0].anchors[0].p, Point::new(100.0, 50.0));
let id = draw(&mut s, "star", 90.0);
assert_eq!(path(&s, id).subpaths[0].anchors[0].p, Point::new(50.0, 100.0));
// Without Shift the drag turns them, whatever the preference.
set_angle(&mut s, 30.0);
s.select_tool("polygon", ViewInfo::default()).unwrap();
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 150.0, 100.0), (Up, 150.0, 100.0)]);
assert!(close(polygon_turn(&s, last_id(&s)), -90.0), "the drag points the polygon right");
}
/// New type is set along the turned axes: point type turns about its first baseline's start, an
/// area's box is measured along the axes; 0° and whole turns as before, 90° exact.
#[test]
fn constrain_angle_sets_new_type_along_the_turned_axes() {
let mut s = new_doc();
let xf = |s: &Session, id: NodeId| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (t.xf, t.kind.clone()),
other => panic!("type: {other:?}"),
};
// Each at its own place, so no click lands in type drawn before.
let type_at = |s: &mut Session, deg: f64, area: bool, y: f64| {
set_angle(s, deg);
s.select_tool("type", ViewInfo::default()).unwrap();
if area {
gesture(s, Mods::default(), &[(Down, 20.0, y), (Drag, 70.0, y + 50.0), (Drag, 120.0, y + 60.0), (Up, 120.0, y + 60.0)]);
} else {
gesture(s, Mods::default(), &[(Down, 300.0, y), (Up, 300.0, y)]);
}
let id = last_id(s);
s.select_tool("selection", ViewInfo::default()).unwrap();
let (m, kind) = xf(s, id);
// Where it was put, and how it is turned and shaped there.
(m.as_coeffs()[4..].to_vec(), m.as_coeffs()[..4].to_vec(), kind, m)
};
let (at, flat_point, ..) = type_at(&mut s, 0.0, false, 20.0);
assert_eq!(at, [300.0, 20.0]);
let (at, flat_area, flat_frame, _) = type_at(&mut s, 0.0, true, 20.0);
assert_eq!(at, [20.0, 20.0]);
for (turn, y) in [(360.0, 110.0), (-360.0, 200.0)] {
let (at, turned, ..) = type_at(&mut s, turn, false, y);
assert_eq!((at, turned), (vec![300.0, y], flat_point.clone()), "point type at {turn}°");
let (at, turned, frame, _) = type_at(&mut s, turn, true, y);
assert_eq!((at, turned, frame), (vec![20.0, y], flat_area.clone(), flat_frame.clone()), "area type at {turn}°");
}
// Point type at 30°: its baseline runs 30° up from the horizontal, from the click.
let (at, turned, ..) = type_at(&mut s, 30.0, false, 290.0);
assert!(close(-turned[1].atan2(turned[0]).to_degrees(), 30.0) && at == [300.0, 290.0], "{turned:?} at {at:?}");
// Area type at 90°: 100 right and 60 down on the page is 60 by 100 along the turned axes, and
// the frame covers the drag exactly.
let (_, _, kind, m) = type_at(&mut s, 90.0, true, 290.0);
let TextKind::Area { frame } = kind else { panic!("area type") };
let local = frame.bounds().unwrap();
assert_eq!((local.width(), local.height()), (60.0, 100.0));
let page = frame.transformed(m).bounds().unwrap();
assert_eq!((page.x0, page.y0, page.x1, page.y1), (20.0, 290.0, 120.0, 350.0));
// Through the command.
let r = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "", "rotation": -90})).unwrap();
let (m, _) = xf(&s, NodeId(r["id"].as_u64().unwrap()));
assert_eq!(m.as_coeffs(), [0.0, -1.0, 1.0, 0.0, 10.0, 20.0]);
assert!(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "", "rotation": "x"})).is_err());
}
/// The top-left corner `x`, `y` of a size dialog's params: the point it places at the click.
fn corner(p: &Value) -> Point {
Point::new(p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0))
}
/// A click's size dialog draws along the turned axes too ([`constrain_click`]): the box keeps
/// its corner at the click, a polygon or star turns about its centre; at 0° and whole turns the
/// params are as they were.
#[test]
fn constrain_angle_turns_shapes_made_by_a_click() {
let mut s = new_doc();
let rect = json!({"x": 10, "y": 200, "width": 100, "height": 50});
for turn in [0.0, 360.0, -360.0] {
set_angle(&mut s, turn);
for (cmd, p) in [("shape.rectangle", rect.clone()), ("shape.polygon", json!({"cx": 10, "cy": 10, "radius": 50, "sides": 6}))] {
assert_eq!(constrain_click(&s, cmd, p.clone(), corner(&p)), p, "{cmd} at {turn}°");
}
}
set_angle(&mut s, 90.0);
let run = |s: &mut Session, cmd: &str, p: Value| {
let at = corner(&p);
let p = constrain_click(s, cmd, p, at);
NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap())
};
for cmd in ["shape.rectangle", "shape.ellipse", "shape.rectangularGrid", "shape.polarGrid"] {
// 100 along the turned x axis (up the page) and 50 along the turned y (right).
let id = run(&mut s, cmd, rect.clone());
let b = bounds(&s, id);
assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 100.0, 60.0, 200.0), "{cmd} at 90°");
}
let p = constrain_click(&s, "shape.star", json!({"cx": 0, "cy": 0, "radius1": 50, "radius2": 25, "points": 5}), Point::ZERO);
assert_eq!(p["rotation"], json!(-90.0));
// An explicit rotation is the caller's.
assert_eq!(constrain_click(&s, "shape.polygon", json!({"rotation": 5}), Point::ZERO)["rotation"], json!(5));
// Malformed params go through as they are, for the command to refuse.
assert_eq!(constrain_click(&s, "shape.polygon", json!([1]), Point::ZERO), json!([1]));
assert_eq!(constrain_click(&s, "shape.rectangle", json!({"x": "a"}), Point::ZERO), json!({"x": "a"}));
set_angle(&mut s, 30.0);
let id = run(&mut s, "shape.rectangle", rect.clone());
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!() };
// The click's corner stays put, the sides run along 30° and 120°.
assert!(path.subpaths[0].anchors.iter().any(|a| close(a.p.x, 10.0) && close(a.p.y, 200.0)), "{path:?}");
for a in segment_angles(path) {
assert!(close(a, 30.0), "a side at {a}°");
}
}
/// The Line Segment tool's Shift steps of 45° count from the Constrain Angle.
#[test]
fn constrain_angle_steps_the_line_segment_tool() {
let mut s = new_doc();
let shift = Mods { shift: true, ..Mods::default() };
let line = |s: &mut Session, deg: f64, (x, y): (f64, f64)| {
set_angle(s, deg);
s.select_tool("lineSegment", ViewInfo::default()).unwrap();
gesture(s, shift, &[(Down, 100.0, 100.0), (Drag, 150.0, 102.0), (Drag, x, y), (Up, x, y)]);
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(last_id(s)).unwrap().kind else { panic!() };
let v = path.subpaths[0].anchors[1].p - path.subpaths[0].anchors[0].p;
(-v.y).atan2(v.x).to_degrees()
};
let level = (200.0, 105.0);
assert_eq!(line(&mut s, 0.0, level), 0.0);
assert_eq!(line(&mut s, 360.0, level), 0.0);
// Nearly level: the step nearest it, counted from 30°, is −15°.
assert!(close(line(&mut s, 30.0, level), -15.0));
assert!(close(line(&mut s, 30.0, (200.0, 45.0)), 30.0), "along the turned x axis");
assert!(close(line(&mut s, 90.0, level), 0.0));
}
fn selection_rotation(s: &Session) -> f64 {
// What `document.inspect` reports as `selectionRotation`.
let st = s.doc().unwrap();
st.doc.bbox_angle(&st.selection.objects)
}
fn bbox_angle(s: &Session, id: NodeId) -> f64 {
s.doc().unwrap().doc.node(id).unwrap().bbox_angle
}
/// Objects drawn along a Constrain Angle keep a bounding box turned with them, as after the Rotate
/// tool: at 30° the selection's rotation is 30°, the same as a rectangle turned 30° by
/// `object.rotate`; at 0° and whole turns the box stays on the page (exactly 0, not −0).
#[test]
fn constrain_angle_turns_the_new_objects_bounding_box() {
let mut s = new_doc();
s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let rotated = selection_rotation(&s);
assert!(close(rotated, 30.0), "{rotated}");
let shift = Mods { shift: true, ..Mods::default() };
type Gesture = [(PointerKind, f64, f64); 4];
let draws: [(&str, Mods, Gesture); 6] = [
("rectangle", Mods::default(), [(Down, 20.0, 20.0), (Drag, 50.0, 40.0), (Drag, 80.0, 60.0), (Up, 80.0, 60.0)]),
("ellipse", Mods::default(), [(Down, 20.0, 120.0), (Drag, 50.0, 140.0), (Drag, 80.0, 160.0), (Up, 80.0, 160.0)]),
("polygon", shift, [(Down, 150.0, 50.0), (Drag, 170.0, 70.0), (Drag, 180.0, 80.0), (Up, 180.0, 80.0)]),
("star", shift, [(Down, 150.0, 150.0), (Drag, 170.0, 170.0), (Drag, 180.0, 180.0), (Up, 180.0, 180.0)]),
("rectangularGrid", Mods::default(), [(Down, 220.0, 20.0), (Drag, 250.0, 40.0), (Drag, 280.0, 60.0), (Up, 280.0, 60.0)]),
("polarGrid", Mods::default(), [(Down, 220.0, 120.0), (Drag, 250.0, 140.0), (Drag, 280.0, 160.0), (Up, 280.0, 160.0)]),
];
for deg in [0.0, 360.0, 30.0] {
set_angle(&mut s, deg);
for (tool, mods, events) in &draws {
s.select_tool(tool, ViewInfo::default()).unwrap();
gesture(&mut s, *mods, events);
let id = last_id(&s);
if deg == 30.0 {
assert!(close(selection_rotation(&s), rotated), "{tool}: {}", selection_rotation(&s));
} else {
assert_eq!(bbox_angle(&s, id).to_bits(), 0.0f64.to_bits(), "{tool} at {deg}°");
}
}
}
// A polygon turned by the drag's direction, not the Constrain Angle, keeps its box on the page.
s.select_tool("polygon", ViewInfo::default()).unwrap();
gesture(&mut s, Mods::default(), &[(Down, 150.0, 250.0), (Drag, 160.0, 260.0), (Drag, 190.0, 270.0), (Up, 190.0, 270.0)]);
assert_eq!(bbox_angle(&s, last_id(&s)).to_bits(), 0.0f64.to_bits(), "a polygon turned by the drag");
// A grid's lines turn their boxes with it, and so does new type.
let id =
NodeId(s.execute("shape.polarGrid", &json!({"x": 0, "y": 0, "width": 40, "height": 20, "rotation": -30})).unwrap()["id"].as_u64().unwrap());
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.children().unwrap().iter().all(|c| close(c.bbox_angle, 30.0)));
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "rotation": -30})).unwrap();
assert!(close(selection_rotation(&s), 30.0));
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi"})).unwrap();
assert_eq!(selection_rotation(&s).to_bits(), 0.0f64.to_bits());
}
/// The grid commands refuse a `rotation` that is not a number.
#[test]
fn grid_rotation_must_be_a_number() {
let mut s = new_doc();
for cmd in ["shape.rectangularGrid", "shape.polarGrid"] {
for bad in [json!("x"), Value::Null] {
assert!(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 40, "height": 20, "rotation": bad})).is_err(), "{cmd}: {bad}");
}
}
}
/// Area type dragged at 30°: its frame runs along the turned axes with the drag's start and end
/// at opposite corners; vertical type takes a rotation as horizontal type does.
#[test]
fn constrain_angle_sets_area_and_vertical_type() {
let mut s = new_doc();
set_angle(&mut s, 30.0);
s.select_tool("type", ViewInfo::default()).unwrap();
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 150.0, 150.0), (Drag, 250.0, 200.0), (Up, 250.0, 200.0)]);
let id = last_id(&s);
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("type") };
let [a, b, ..] = t.xf.as_coeffs();
assert!(close(-b.atan2(a).to_degrees(), 30.0), "{:?}", t.xf);
let TextKind::Area { frame } = &t.kind else { panic!("area type") };
// The drag (150 right, 100 down) measured along axes turned 30° counter-clockwise.
let r = 30f64.to_radians();
let (w, h) = (150.0 * r.cos() - 100.0 * r.sin(), 150.0 * r.sin() + 100.0 * r.cos());
let local = frame.bounds().unwrap();
assert!(close(local.width(), w.abs()) && close(local.height(), h.abs()), "{local:?} vs {w} × {h}");
let corners: Vec<Point> = [(local.x0, local.y0), (local.x1, local.y0), (local.x0, local.y1), (local.x1, local.y1)]
.iter()
.map(|&(x, y)| t.xf * Point::new(x, y))
.collect();
for p in [Point::new(100.0, 100.0), Point::new(250.0, 200.0)] {
assert!(corners.iter().any(|c| close(c.x, p.x) && close(c.y, p.y)), "{p:?} in {corners:?}");
}
assert!(close(selection_rotation(&s), 30.0));
s.select_tool("selection", ViewInfo::default()).unwrap();
// Vertical type with a rotation.
let r = s.execute("text.create", &json!({"x": 300, "y": 50, "text": "Up", "vertical": true, "rotation": -90})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("type") };
assert!(t.vertical);
assert_eq!(t.xf.as_coeffs(), [0.0, -1.0, 1.0, 0.0, 300.0, 50.0]);
assert_eq!(bbox_angle(&s, id), 90.0);
}
/// A grid dialog placed by its reference point keeps that point at the click when the grid turns
/// along the Constrain Angle: at 90° with the centre as the reference, the box turns about it.
#[test]
fn constrain_angle_turns_a_click_made_grid_about_its_reference_point() {
let mut s = new_doc();
set_angle(&mut s, 90.0);
// A 100 × 50 grid centred on the click at (200, 200).
let p = json!({"x": 150, "y": 175, "width": 100, "height": 50});
let p = constrain_click(&s, "shape.rectangularGrid", p, Point::new(200.0, 200.0));
let id = NodeId(s.execute("shape.rectangularGrid", &p).unwrap()["id"].as_u64().unwrap());
let b = bounds(&s, id);
assert_eq!((b.x0, b.y0, b.x1, b.y1), (175.0, 150.0, 225.0, 250.0), "turned about the click: {b:?}");
}
+154
View File
@@ -0,0 +1,154 @@
//! General › Constrain Angle turns the axes the arrow keys, Shift-constrained drags and the
//! Rectangle, Rounded Rectangle and Ellipse tools work along (G12, #394, first batch).
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, NodeKind};
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn new_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn set_pref(s: &mut Session, key: &str, value: Value) {
s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
}
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap())
}
fn centre(s: &Session, id: NodeId) -> Point {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center()
}
fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) {
for (k, x, y) in events {
s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), ViewInfo { smart_guides: false, ..ViewInfo::default() }).unwrap();
}
}
/// The live rectangle or ellipse `id`: (width, height, rotation in degrees counter-clockwise on
/// the page, corner radius).
fn live_box(s: &Session, id: NodeId) -> (f64, f64, f64, f64) {
let n = s.doc().unwrap().doc.node(id).unwrap();
let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("a live shape: {:?}", n.kind) };
let (w, h, xf, r) = match &**live {
LiveShape::Rectangle { w, h, xf, radii, .. } => (*w, *h, *xf, radii[0]),
LiveShape::Ellipse { w, h, xf, .. } => (*w, *h, *xf, 0.0),
other => panic!("a rectangle or ellipse: {other:?}"),
};
let [a, b, ..] = xf.as_coeffs();
(w, h, -b.atan2(a).to_degrees(), r)
}
fn last_id(s: &Session) -> NodeId {
*s.doc().unwrap().selection.objects.first().unwrap()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// General › Constrain Angle turns the arrow keys' axes: at 30°, → moves the art 30° up from the
/// horizontal and ↓ 30° round from straight down; ruler guides keep to the page's axes.
#[test]
fn constrain_angle_turns_the_arrow_keys() {
let mut s = new_doc();
let id = rect(&mut s, 100.0, 100.0);
set_pref(&mut s, "keyboardIncrement", json!(10));
let at = centre(&s, id);
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
assert!(close(centre(&s, id).x - at.x, 10.0) && close(centre(&s, id).y, at.y), "0°: straight right");
set_pref(&mut s, "constrainAngle", json!(30));
let at = centre(&s, id);
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
let d = centre(&s, id) - at;
assert!(close(d.x, 10.0 * 30f64.to_radians().cos()) && close(d.y, -5.0), "→ at 30°: {d:?}");
let at = centre(&s, id);
s.execute("object.nudge", &json!({"dx": 0, "dy": 1})).unwrap();
let d = centre(&s, id) - at;
assert!(close(d.x, 5.0) && close(d.y, 10.0 * 30f64.to_radians().cos()), "↓ at 30°: {d:?}");
// Ruler guides: along the page.
let g = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap();
s.execute("guide.select", &json!({"indexes": [g]})).unwrap();
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
let pos = s.doc().unwrap().doc.guides[g as usize].pos;
assert!(close(pos, 60.0), "a vertical guide moves 10 right: {pos}");
}
/// General › Constrain Angle holds Shift-drags to the turned axes: a Selection-tool move dragged
/// 2° off the 30° axis lands on it.
#[test]
fn constrain_angle_holds_shift_drags_to_the_turned_axes() {
use PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let id = rect(&mut s, 100.0, 200.0);
s.select_tool("selection", ViewInfo::default()).unwrap();
let shift = Mods { shift: true, ..Mods::default() };
let drag = |s: &mut Session| {
let at = centre(s, id);
let a = 32f64.to_radians();
let to = at + Vec2::new(a.cos(), -a.sin()) * 100.0;
gesture(s, shift, &[(Down, at.x, at.y), (Drag, at.x + 20.0, at.y - 5.0), (Drag, to.x, to.y), (Up, to.x, to.y)]);
let d = centre(s, id) - at;
s.execute("edit.undo", &json!({})).unwrap();
-d.y.atan2(d.x).to_degrees()
};
let a = drag(&mut s);
assert!(close(a, 45.0), "0°: 45° steps from the page's axes, {a}");
set_pref(&mut s, "constrainAngle", json!(30));
let a = drag(&mut s);
assert!(close(a, 30.0), "30°: the drag keeps to the turned x axis, {a}");
}
/// General › Constrain Angle: rectangles, rounded rectangles and ellipses are drawn along the
/// turned axes (the drag's width and height measured along them).
#[test]
fn constrain_angle_draws_boxes_along_the_turned_axes() {
use PointerKind::{Down, Drag, Up};
let mut s = new_doc();
set_pref(&mut s, "constrainAngle", json!(90));
for tool in ["rectangle", "ellipse"] {
s.select_tool(tool, ViewInfo::default()).unwrap();
// 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high.
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]);
let id = last_id(&s);
let (w, h, rot, _) = live_box(&s, id);
assert!(close(w, 20.0) && close(h, 60.0) && close(rot.rem_euclid(360.0), 90.0), "{tool}: {w} × {h} at {rot}°");
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!(close(b.x0, 100.0) && close(b.y0, 100.0) && close(b.x1, 160.0) && close(b.y1, 120.0), "{tool}: covers the drag, {b:?}");
}
set_pref(&mut s, "constrainAngle", json!(0));
s.select_tool("rectangle", ViewInfo::default()).unwrap();
gesture(&mut s, Mods::default(), &[(Down, 100.0, 200.0), (Drag, 130.0, 210.0), (Drag, 160.0, 220.0), (Up, 160.0, 220.0)]);
let (w, h, rot, _) = live_box(&s, last_id(&s));
assert!(close(w, 60.0) && close(h, 20.0) && close(rot, 0.0), "0°: along the page, {w} × {h} at {rot}°");
}
/// General › Constrain Angle of a whole turn (±360°) is no turn: the arrow keys move along
/// the page exactly as at 0°, with no floating-point noise.
#[test]
fn constrain_angle_whole_turns_nudge_as_zero() {
let mut s = new_doc();
let id = rect(&mut s, 100.0, 100.0);
set_pref(&mut s, "keyboardIncrement", json!(10));
let moved = |s: &mut Session, dx: i32, dy: i32| {
let at = centre(s, id);
s.execute("object.nudge", &json!({"dx": dx, "dy": dy})).unwrap();
centre(s, id) - at
};
let flat = [moved(&mut s, 1, 0), moved(&mut s, 0, 1)];
// The preference takes −360…360.
for turn in [360, -360] {
set_pref(&mut s, "constrainAngle", json!(turn));
assert_eq!([moved(&mut s, 1, 0), moved(&mut s, 0, 1)], flat, "{turn}°");
}
// A quarter turn is exact too: → moves straight up.
set_pref(&mut s, "constrainAngle", json!(90));
assert_eq!(moved(&mut s, 1, 0), Vec2::new(0.0, -10.0));
}
+85
View File
@@ -58,11 +58,63 @@ pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 {
if len < EPS {
return v;
}
// A whole turn (360°, −720°…) is no turn at all.
let base_deg = if base_deg.is_finite() { normalize_deg(base_deg) } else { 0.0 };
let (step, base) = (step_deg.to_radians(), -base_deg.to_radians());
let a = base + ((v.y.atan2(v.x) - base) / step).round() * step;
Vec2::new(a.cos() * len, a.sin() * len)
}
/// The page axes turned by `deg` (the Constrain Angle preference, counter-clockwise as seen on
/// the y-down page): maps a vector along the turned axes onto the page. `None` when they are the
/// page's own axes (any multiple of 360°, or a non-finite angle), so callers keep exact page
/// coordinates; multiples of 90° are exact quarter turns, without floating-point noise.
pub fn constrain_axes(deg: f64) -> Option<Affine> {
if !deg.is_finite() {
return None;
}
let d = normalize_deg(deg);
let (cos, sin) = if d == 0.0 {
return None;
} else if d == 90.0 {
(0.0, 1.0)
} else if d == 180.0 {
(-1.0, 0.0)
} else if d == -90.0 {
(0.0, -1.0)
} else {
let r = d.to_radians();
(r.cos(), r.sin())
};
// Counter-clockwise on a y-down page is a negative rotation of the coordinates.
Some(Affine::new([cos, -sin, sin, cos, 0.0, 0.0]))
}
/// A rotation by `deg` (clockwise as seen on the y-down page, as [`Affine::rotate`] takes it in
/// radians), exact at quarter turns and the identity at whole turns; 0 is `Affine::rotate(0.0)`
/// as before, and any other angle is `Affine::rotate` itself.
pub fn rotate_deg(deg: f64) -> Affine {
if deg.is_finite() && deg != 0.0 {
let d = normalize_deg(deg);
if d == 0.0 {
return Affine::IDENTITY;
}
if d % 90.0 == 0.0
&& let Some(a) = constrain_axes(-d)
{
return a;
}
}
Affine::rotate(deg.to_radians())
}
/// The `rotation` (degrees, as the shape and type commands take it) that turns a new object from
/// the page axes onto the axes of Constrain Angle `base`; `None` for the page's own axes (0°, a
/// whole turn, a non-finite angle), where new objects are drawn exactly as without it.
pub fn constrain_rotation(base: f64) -> Option<f64> {
constrain_axes(base).map(|_| -normalize_deg(base))
}
/// Rect from two corner points in any order.
pub fn rect_from_points(a: Point, b: Point) -> Rect {
Rect::from_points(a, b)
@@ -79,6 +131,39 @@ pub fn reference_point(r: Rect, index: usize) -> Point {
mod tests {
use super::*;
#[test]
fn constrain_axes_are_exact_and_whole_turns_are_none() {
for d in [0.0, 360.0, -360.0, 720.0, f64::NAN, f64::INFINITY] {
assert!(constrain_axes(d).is_none(), "{d}");
}
let v = Point::new(3.0, 4.0);
// 90° counter-clockwise on the page: x points up (−y).
assert_eq!(constrain_axes(90.0).unwrap() * Point::new(1.0, 0.0), Point::new(0.0, -1.0));
assert_eq!(constrain_axes(450.0).unwrap() * v, constrain_axes(90.0).unwrap() * v);
assert_eq!(constrain_axes(-90.0).unwrap() * v, Point::new(-4.0, 3.0));
assert_eq!(constrain_axes(180.0).unwrap() * v, Point::new(-3.0, -4.0));
assert_eq!(constrain_axes(-180.0).unwrap() * v, Point::new(-3.0, -4.0));
let a = constrain_axes(30.0).unwrap() * Point::new(1.0, 0.0);
assert!((a.x - 30f64.to_radians().cos()).abs() < 1e-12 && (a.y + 0.5).abs() < 1e-12, "{a:?}");
assert_eq!(constrain_angle_from(Vec2::new(5.0, 1.0), 45.0, 360.0), constrain_angle_from(Vec2::new(5.0, 1.0), 45.0, 0.0));
}
#[test]
fn rotate_deg_is_exact_at_quarter_turns() {
let v = Point::new(3.0, 4.0);
assert_eq!(rotate_deg(90.0) * v, Point::new(-4.0, 3.0));
assert_eq!(rotate_deg(-90.0) * v, Point::new(4.0, -3.0));
assert_eq!(rotate_deg(180.0) * v, Point::new(-3.0, -4.0));
assert_eq!(rotate_deg(-270.0) * v, rotate_deg(90.0) * v);
assert_eq!(rotate_deg(360.0) * v, v);
assert_eq!(rotate_deg(0.0), Affine::rotate(0.0));
assert_eq!(rotate_deg(30.0), Affine::rotate(30f64.to_radians()));
assert_eq!(constrain_rotation(30.0), Some(-30.0));
assert_eq!(constrain_rotation(0.0), None);
assert_eq!(constrain_rotation(-360.0), None);
assert_eq!(constrain_rotation(450.0), Some(-90.0));
}
#[test]
fn angle_normalization() {
assert_eq!(normalize_deg(190.0), -170.0);
+5 -3
View File
@@ -117,10 +117,12 @@ pub fn rotate_for_drag(center: Point, from: Point, to: Point, snap: bool) -> (Af
(Affine::translate(center.to_vec2()) * Affine::rotate(a) * Affine::translate(-center.to_vec2()), -a.to_degrees())
}
/// Move constrained to 45° multiples when `shift`.
pub fn move_delta(from: Point, to: Point, shift: bool) -> Vec2 {
/// Move constrained to 45° multiples when `shift`, counted from `base` degrees (General ›
/// Constrain Angle, [`crate::ToolContext::constrain_angle`]): Shift holds a drag to the rotated x
/// and y axes and their diagonals.
pub fn move_delta(from: Point, to: Point, shift: bool, base: f64) -> Vec2 {
let d = to - from;
if shift { vectorcraft_geom::constrain_angle(d, 45.0) } else { d }
if shift { vectorcraft_geom::constrain_angle_from(d, 45.0, base) } else { d }
}
#[cfg(test)]
+5 -5
View File
@@ -629,7 +629,7 @@ impl Tool for DirectSelectionTool {
self.state = State::MoveAnchors { start, grab, began: true };
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
}
let mut d = move_delta(start, p, ev.mods.shift);
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
let unsnapped = d;
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
@@ -675,7 +675,7 @@ impl Tool for DirectSelectionTool {
self.move_snap = Some(MoveSnap::new(cx));
}
}
let mut d = move_delta(start, p, ev.mods.shift);
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
let unsnapped = d;
self.guides.clear();
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
@@ -701,7 +701,7 @@ impl Tool for DirectSelectionTool {
}
out.push(Action::Begin("Reshape Type Area".into()));
}
let d = move_delta(start, p, ev.mods.shift);
let d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
out.push(Action::Preview("text.reshapeArea".into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y})));
self.state = State::TypeArea { id, anchors, start, began: true };
out
@@ -724,7 +724,7 @@ impl Tool for DirectSelectionTool {
out.push(Action::Begin("Reshape Spine".into()));
self.state = State::SpinePoint { id, anchor, from, start, began: true };
}
let q = from + move_delta(start, p, ev.mods.shift);
let q = from + move_delta(start, p, ev.mods.shift, cx.constrain_angle);
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
out
}
@@ -1975,7 +1975,7 @@ mod tests {
assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}");
// …and with nothing in reach, at the pointer's distance along its angle.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift);
let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true);
let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true, 0.0);
assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}");
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
let two = anchors_of(b, &[(0, 0), (0, 1)]);
+45 -13
View File
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
use vectorcraft_geom::Point;
use crate::guides::{DrawSnap, Leave};
use crate::shape::drag_rect;
use crate::shape::{box_params, drag_rect, turned_box};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct FamilyTool {
@@ -88,13 +88,25 @@ impl FamilyTool {
}
/// 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 grids' corner keeps to a diagonal with Shift (a grid's square is taken along the
/// Constrain Angle's axes instead when it turns them, [`Self::command_at`]).
fn leave(&self, cx: &ToolContext, start: Point, m: Mods) -> Option<Leave> {
let turned = self.is_grid() && vectorcraft_geom::constrain_axes(cx.constrain_angle).is_some();
(self.id != "spiral").then(|| Leave::diagonal(start, m.shift && !turned))
}
fn is_grid(&self) -> bool {
matches!(self.id, "rectangularGrid" | "polarGrid")
}
/// The command for a drag from `start` to `p`.
pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) {
self.command_at(start, p, m, 0.0)
}
/// [`Self::command`] with General › Constrain Angle `base` (degrees, counter-clockwise): the
/// grids are drawn along the x and y axes turned by it, as the rectangle is.
pub fn command_at(&self, start: Point, p: Point, m: Mods, base: f64) -> (String, Value) {
match self.id {
"spiral" => {
let r = p.distance(start).max(0.01);
@@ -103,12 +115,11 @@ impl FamilyTool {
json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
)
}
// The grids: the dragged box with the tool's options.
// The grids: the dragged box (along the Constrain Angle's axes) with the tool's options.
"rectangularGrid" | "polarGrid" => {
let r = drag_rect(start, p, m);
let mut params = self.options();
if let Some(o) = params.as_object_mut() {
o.extend([("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())].map(|(k, v)| (k.to_string(), json!(v))));
if let (Some(o), Value::Object(b)) = (params.as_object_mut(), box_params(start, p, m, base)) {
o.extend(b);
}
(format!("shape.{}", self.id), params)
}
@@ -158,7 +169,7 @@ 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 pos = self.snap.drag(cx, ev.pos, self.leave(cx, 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);
@@ -171,7 +182,7 @@ impl Tool for FamilyTool {
self.began = true;
out.push(Action::Begin(self.label().into()));
}
let (c, v) = self.command(s, ev.pos, ev.mods);
let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle);
out.push(Action::Preview(c, v));
out
}
@@ -183,7 +194,7 @@ impl Tool for FamilyTool {
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
let changed = match (self.id, key) {
("spiral", ToolKey::Up) => {
@@ -213,7 +224,7 @@ impl Tool for FamilyTool {
_ => false,
};
if changed {
let (c, v) = self.command(s, self.last, self.mods);
let (c, v) = self.command_at(s, self.last, self.mods, cx.constrain_angle);
vec![Action::Preview(c, v)]
} else if key == ToolKey::Escape {
self.start = None;
@@ -228,7 +239,8 @@ impl Tool for FamilyTool {
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);
// A grid's along the Constrain Angle's axes, as it is drawn.
let r = if self.is_grid() { turned_box(s, self.last, self.mods, cx.constrain_angle).0 } else { 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) });
}
@@ -425,6 +437,26 @@ mod tests {
);
}
/// General › Constrain Angle: grids are drawn along the turned axes, Shift's square too; at 0°
/// and whole turns as before.
#[test]
fn grids_follow_the_constrain_angle() {
let t = FamilyTool::new("polarGrid");
let (a, b) = (Point::new(100.0, 100.0), Point::new(160.0, 120.0));
let flat = t.command(a, b, Mods::default());
assert_eq!(t.command_at(a, b, Mods::default(), 360.0), flat);
assert_eq!(t.command_at(a, b, Mods::default(), -720.0), flat);
let (_, v) = t.command_at(a, b, Mods::default(), 90.0);
assert_eq!(
v,
json!({"x": 120.0, "y": 80.0, "width": 20.0, "height": 60.0, "rotation": -90.0, "concentric": 5, "radial": 5, "concentricSkew": 0.0, "radialSkew": 0.0, "compound": false, "fill": false})
);
let shift = Mods { shift: true, ..Mods::default() };
let (_, v) = FamilyTool::new("rectangularGrid").command_at(a, b, shift, 30.0);
assert!((v["width"].as_f64().unwrap() - v["height"].as_f64().unwrap()).abs() < 1e-9, "a square along the turned axes: {v}");
assert_eq!(v["rotation"], json!(-30.0));
}
/// 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]
+1 -1
View File
@@ -276,7 +276,7 @@ impl Tool for ReshapeTool {
let Some((id, at)) = self.grab else { return vec![] };
let mut d: Vec2 = ev.pos - at;
if ev.mods.shift {
d = vectorcraft_geom::constrain_angle(d, 45.0);
d = vectorcraft_geom::constrain_angle_from(d, 45.0, cx.constrain_angle);
}
let mut out = vec![];
if !self.began {
+2 -2
View File
@@ -305,7 +305,7 @@ impl Tool for PenTool {
if let Some((id, si, ai, a)) = self.handle {
let mut h = ev.pos;
if ev.mods.shift {
h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
h = a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle);
}
return vec![set_out_handle(id, si, ai, h)];
}
@@ -320,7 +320,7 @@ impl Tool for PenTool {
self.last = ev.pos;
let mut out_h = ev.pos;
if ev.mods.shift {
out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
out_h = a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle);
}
let alt = ev.mods.alt;
let Some(id) = path else {
+1 -1
View File
@@ -712,7 +712,7 @@ impl Tool for SelectionTool {
out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() }));
self.moving = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, m.shift);
let mut d = move_delta(start, p, m.shift, cx.constrain_angle);
let unsnapped = d;
if let Some(snap) = &self.moving {
(d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) };
+92 -9
View File
@@ -8,7 +8,7 @@
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::guides::{DrawSnap, Leave, square};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -96,18 +96,29 @@ impl ShapeTool {
match self.id {
"polygon" | "star" => None,
"lineSegment" => Some(Leave::segment(cx, start, m.shift)),
_ => Some(Leave::diagonal(start, m.shift)),
// With a Constrain Angle, Shift's square is taken along the rotated axes
// ([`Self::command_at`]), not the page's diagonal.
_ => Some(Leave::diagonal(start, m.shift && vectorcraft_geom::constrain_axes(cx.constrain_angle).is_none())),
}
}
/// 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) {
self.command_at(start, p, m, 0.0)
}
/// [`Self::command`] with General › Constrain Angle `base` (degrees, counter-clockwise):
/// rectangles, rounded rectangles and ellipses are drawn along the x and y axes turned by it,
/// as the reference app draws new objects along the rotated axes
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>).
pub fn command_at(&self, start: Point, p: Point, m: Mods, base: f64) -> (String, Value) {
match self.id {
"polygon" | "star" => {
let v = p - start;
let r = v.hypot().max(0.01);
let rot = if m.shift { 0.0 } else { (v.atan2().to_degrees() + 90.0) % 360.0 };
// Shift keeps the base orientation: upright, turned by the Constrain Angle.
let rot = if m.shift { vectorcraft_geom::constrain_rotation(base).unwrap_or(0.0) } else { (v.atan2().to_degrees() + 90.0) % 360.0 };
if self.id == "polygon" {
("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot }))
} else {
@@ -123,9 +134,8 @@ impl ShapeTool {
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
}
_ => {
let r = drag_rect(start, p, m);
let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" };
let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() });
let mut v = box_params(start, p, m, base);
if self.id == "roundedRectangle" {
v["radius"] = json!(self.corner_radius);
}
@@ -135,6 +145,34 @@ impl ShapeTool {
}
}
/// The box a drag from `start` to `p` draws (from its centre with Alt, square with Shift),
/// measured along the page axes turned by Constrain Angle `base`: the box in those axes, and the
/// axes (`None` for the page's own: 0° or a whole turn, where the box is on the page exactly).
/// The drawn shape and its size label both come from it.
pub(crate) fn turned_box(start: Point, p: Point, m: Mods, base: f64) -> (Rect, Option<Affine>) {
match vectorcraft_geom::constrain_axes(base) {
None => (drag_rect(start, p, m), None),
Some(axes) => {
let back = axes.inverse();
(drag_rect(back * start, back * p, m), Some(axes))
}
}
}
/// The `{x, y, width, height, rotation?}` params of the box a drag from `start` to `p` draws along
/// Constrain Angle `base` ([`turned_box`]): the box's page position at 0° or a whole turn, else
/// the turned box's centre out on the page again with the rotation that turns it about it.
pub(crate) fn box_params(start: Point, p: Point, m: Mods, base: f64) -> Value {
match turned_box(start, p, m, base) {
(r, None) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }),
(r, Some(axes)) => {
let c = axes * r.center();
let rotation = -vectorcraft_geom::normalize_deg(base);
json!({ "x": c.x - r.width() / 2.0, "y": c.y - r.height() / 2.0, "width": r.width(), "height": r.height(), "rotation": rotation })
}
}
}
/// Space held once a shape drag has begun moves the shape being drawn instead of sizing it: `start`
/// moves as far as the pointer did since `last`, which becomes the pointer.
pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent, began: bool) {
@@ -189,7 +227,7 @@ impl Tool for ShapeTool {
self.began = true;
out.push(Action::Begin(self.label().into()));
}
let (c, v) = self.command(s, ev.pos, ev.mods);
let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle);
out.push(Action::Preview(c, v));
out
}
@@ -209,7 +247,7 @@ impl Tool for ShapeTool {
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
let changed = match (self.id, key) {
("polygon", ToolKey::Up) => {
@@ -239,7 +277,7 @@ impl Tool for ShapeTool {
_ => false,
};
if changed {
let (c, v) = self.command(s, self.last, self.mods);
let (c, v) = self.command_at(s, self.last, self.mods, cx.constrain_angle);
vec![Action::Preview(c, v)]
} else if key == ToolKey::Escape {
self.start = None;
@@ -261,7 +299,8 @@ impl Tool for ShapeTool {
let (w, h) = match self.id {
"polygon" | "star" => ((self.last - s).x.abs(), (self.last - s).y.abs()),
_ => {
let r = drag_rect(s, self.last, self.mods);
// Along the Constrain Angle's axes, as the box is drawn (`command_at`).
let (r, _) = turned_box(s, self.last, self.mods, cx.constrain_angle);
(r.width(), r.height())
}
};
@@ -373,6 +412,50 @@ mod tests {
assert_eq!(v, json!({"x": -10.0, "y": -10.0, "width": 20.0, "height": 20.0}));
}
/// General › Constrain Angle: a Shift-drag squares along the turned axes, and the shape turns
/// by the angle about its centre.
#[test]
fn constrain_angle_squares_along_the_turned_axes() {
let t = ShapeTool::new("rectangle");
let shift = Mods { shift: true, ..Default::default() };
// 45°: the drag straight right is a diagonal of the turned axes, so a square on it.
let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 0.0), shift, 45.0);
let side = 20.0 / 2f64.sqrt();
let near = |k: &str, want: f64| (v[k].as_f64().unwrap() - want).abs() < 1e-9;
assert!(near("width", side) && near("height", side) && near("rotation", -45.0), "{v}");
assert!(near("x", 10.0 - side / 2.0) && near("y", -side / 2.0), "centred on the drag's middle: {v}");
// Whole turns are the page's axes exactly: the same box as 0°, unturned.
let (a, b) = (Point::new(3.0, 4.0), Point::new(41.0, 17.0));
let flat = t.command_at(a, b, Mods::default(), 0.0);
for turn in [360.0, -360.0, 720.0] {
assert_eq!(t.command_at(a, b, Mods::default(), turn), flat, "{turn}°");
}
// A quarter turn is exact (no floating-point noise in the box).
let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 90.0);
assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v["rotation"].as_f64()), (Some(10.0), Some(20.0), Some(-90.0)), "{v}");
let (_, w) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 450.0);
assert_eq!(v, w, "450° is 90°");
}
/// The size label gives the box as drawn: measured along the Constrain Angle's axes.
#[test]
fn constrain_angle_size_label_measures_the_turned_box() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.constrain_angle = 45.0;
let mut t = ShapeTool::new("rectangle");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 340.0, 300.0));
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
let (w, h) = (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap());
assert!((w - h).abs() < 1e-9 && (w - 40.0 / 2f64.sqrt()).abs() < 1e-9, "{v}");
let o = t.overlays(&cx);
assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(w, h))), "{o:?}");
assert!(!o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(40.0, 0.0))), "not the page box");
}
#[test]
fn click_opens_dialog() {
let (d, _) = doc_with_rect();
+1 -1
View File
@@ -259,7 +259,7 @@ impl Tool for SliceSelectionTool {
}
out.push(Action::Begin("Move Slice".into()));
}
let d = move_delta(start, p, m.shift);
let d = move_delta(start, p, m.shift, cx.constrain_angle);
out.push(Action::Preview("object.slice.move".into(), json!({ "slices": json_ids(&ids), "dx": d.x, "dy": d.y })));
self.drag = Some(Drag::Move { start, ids, began: true });
out
+36 -5
View File
@@ -283,7 +283,17 @@ impl TypeTool {
let was_editing = self.editing.is_some_and(|id| Self::text(cx, id).is_some());
let mut out = self.finish(cx);
let drag = self.drag.take();
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
// New type is set along General › Constrain Angle's axes (none at 0° or a whole turn): an
// area's box measured along them, turned about its top-left corner, point type about its
// first baseline's start.
let axes = vectorcraft_geom::constrain_axes(cx.constrain_angle);
let back = axes.map(|a| a.inverse());
let area = drag
.map(|d| match back {
Some(b) => Rect::from_points(b * at, b * d),
None => Rect::from_points(at, d),
})
.filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
if was_editing && area.is_none() {
return out;
}
@@ -301,9 +311,15 @@ impl TypeTool {
}
}
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
Some(r) => {
let o = axes.map_or(r.origin(), |a| a * r.origin());
json!({"x": o.x, "y": o.y, "text": "", "area": {"width": r.width(), "height": r.height()}})
}
None => json!({"x": at.x, "y": at.y, "text": ""}),
};
if let Some(rotation) = vectorcraft_geom::constrain_rotation(cx.constrain_angle) {
params["rotation"] = json!(rotation);
}
params["vertical"] = json!(self.vertical);
params["placeholder"] = json!(cx.placeholder_text);
out.push(Action::Exec("text.create".into(), params));
@@ -850,10 +866,25 @@ impl Tool for TypeTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if let (Some((_, s)), Some(d)) = (self.press, self.drag) {
let r = Rect::from_points(s, d);
o.push(Overlay::Marquee(r));
// The area being drawn (along the Constrain Angle's axes when one is set, as `on_up`
// makes it) and its size.
let size = match vectorcraft_geom::constrain_axes(cx.constrain_angle) {
None => {
let r = Rect::from_points(s, d);
o.push(Overlay::Marquee(r));
r.size()
}
Some(axes) => {
let back = axes.inverse();
let r = Rect::from_points(back * s, back * d);
let mut path = r.to_path(0.1);
path.apply_affine(axes);
o.push(Overlay::Path { path, color: [79, 128, 255], width: 1.0, dashed: true });
r.size()
}
};
if cx.measurement_labels {
o.push(Overlay::Measure { p: d, text: cx.size_label(r.width(), r.height()) });
o.push(Overlay::Measure { p: d, text: cx.size_label(size.width, size.height) });
}
}
// Overflow markers on selected text.
+1 -1
View File
@@ -194,7 +194,7 @@ impl FreeTransformTool {
return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] }));
}
Op::Move => {
let v = move_delta(d.start, p, shift);
let v = move_delta(d.start, p, shift, cx.constrain_angle);
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(v.x, v.y)));
Affine::translate(v)
}
+1 -1
View File
@@ -39,7 +39,7 @@ impl Tool for MeasureTool {
PointerKind::Drag | PointerKind::Up if self.dragging => {
if let Some((a, _)) = self.line {
let b = if ev.mods.shift {
a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0)
a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle)
} else {
crate::guides::snap_draw(cx, ev.pos, &[]).0
};
+12 -2
View File
@@ -256,11 +256,21 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
json!({"x": bx, "y": by, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}),
),
};
// On the active plane while the perspective grid shows, sized in plane units from the click.
// On the active plane while the perspective grid shows, sized in plane units from the click;
// else along General › Constrain Angle, turned about the point the dialog places at the click
// (a grid's reference point, the other shapes' top-left corner or centre).
let options = options.or_else(|| matches!(d.kind.as_str(), "spiral" | "rectangularGrid" | "polarGrid").then(|| params.clone()));
let result = match vectorcraft_engine::perspective_click(&app.session, id, &params, vectorcraft_geom::Point::new(x, y)) {
Some((id, params)) => run_and_close(app, &id, params),
None => run_and_close(app, id, params),
None => {
let pivot = if matches!(d.kind.as_str(), "rectangularGrid" | "polarGrid") {
vectorcraft_geom::reference_point(grid_placed(d), grid_reference(d))
} else {
vectorcraft_geom::Point::new(x, y)
};
let params = vectorcraft_engine::constrain_click(&app.session, id, params, pivot);
run_and_close(app, id, params)
}
}?;
if let Some(options) = options {
app.run("tool.setOption", json!({"tool": d.kind, "values": options}))?;
+3 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -2
View File
File diff suppressed because one or more lines are too long