Rotate View: resize and rotate cursors turn with the view

In a rotated view the resize and rotate cursors were the ones for an
unturned view. With the view turned 90°, the right-middle handle of a
selection's bounding box shows at the middle of the box's bottom edge
and drags up and down, but its cursor was the left-right arrow. This
affected the Selection, Free Transform, Artboard, Slice Selection, Crop
Image, Perspective Grid and Perspective Selection tools, ruler guides,
and the spread handle of a freeform gradient.

Now each of these cursors turns with the view to the nearest 45°. The
Selection tool and the Perspective Selection tool turn the handle's
direction by the view's angle before they round it to a cursor.
ToolContext::view_angle reads the view's angle from the screen frame,
and Cursor::turned turns a resize or rotate cursor by it. The new
Cursor::rotate_about_in_view turns the pointer's direction from the
center by the view before it picks one of the rotate cursor's eight
directions. Cursor::rotate_about keeps its signature and behavior. At
0° every cursor is as it was.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 16:18:28 +07:00
committed by bflatastic
parent d1a6e39b7e
commit a12af589aa
10 changed files with 183 additions and 15 deletions
+14 -1
View File
@@ -215,6 +215,7 @@ impl Tool for CropImageTool {
None if r.contains(p) => Cursor::Move,
None => Cursor::Arrow,
}
.turned(cx.view_angle())
}
/// `rect`: the box as `[x, y, width, height]` once it's been dragged or set (until then it's
@@ -257,7 +258,7 @@ mod tests {
use vectorcraft_geom::Affine;
use super::*;
use crate::testutil::{cx, paint};
use crate::testutil::{cx, paint, turned_view};
/// A 500 × 500 document (one artboard) with a 100 × 50 px image drawn at twice that size from
/// `at`, selected.
@@ -323,6 +324,18 @@ mod tests {
assert!(crop_rect(&t.key(&cx, ToolKey::Enter, Mods::default())).is_none(), "the whole image: nothing to cut");
}
/// In a view turned 90° the handles' cursors turn with the view.
#[test]
fn handle_cursors_turn_with_the_view() {
let (d, s) = doc_with_image((50.0, 60.0));
let p = paint();
let mut c = cx(&d, &s, &p);
c.screen = turned_view(90.0);
let t = CropImageTool::default();
assert_eq!(t.cursor(&c, Point::new(50.0, 60.0), Mods::default()), Cursor::ResizeNeSw, "top-left");
assert_eq!(t.cursor(&c, Point::new(150.0, 60.0), Mods::default()), Cursor::ResizeH, "top-middle");
}
#[test]
fn the_box_starts_on_the_part_over_the_artboard_and_escape_cancels() {
// 200 × 100 from (-50, 100): its left 50 pt hang off the artboard.
+14 -1
View File
@@ -1040,6 +1040,7 @@ impl Tool for PerspectiveGridTool {
Some(_) => Cursor::Move,
None => Cursor::Arrow,
}
.turned(cx.view_angle())
}
fn busy(&self) -> bool {
self.drag.is_some() || self.plane.is_some()
@@ -1280,7 +1281,8 @@ impl Tool for PerspectiveSelectionTool {
&& let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0)))
&& let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center()))
{
return resize_cursor(a - c);
// The handle's direction on screen: the view turns it clockwise.
return resize_cursor((Affine::rotate(cx.view_angle().to_radians()) * (a - c).to_point()).to_vec2());
}
Cursor::Arrow
}
@@ -1398,6 +1400,13 @@ mod tests {
let c = cx(&d, &s, &p);
let g = PerspectiveGrid::effective(&d);
let mut t = PerspectiveGridTool::default();
// The horizon handle drags up and down; in a view turned 90° it drags left and right on
// screen.
let horizon = handles(&g, c.tol(1.0)).into_iter().find(|h| h.0 == Handle::Horizon).map(|h| h.1).unwrap();
assert_eq!(t.cursor(&c, horizon, Mods::default()), Cursor::ResizeV);
let mut turned = cx(&d, &s, &p);
turned.screen = turned_view(90.0);
assert_eq!(t.cursor(&turned, horizon, Mods::default()), Cursor::ResizeH);
// Click the right face of the widget.
let (faces, _) = g.widget(&d, 1.0);
let q = faces[1].1;
@@ -1484,6 +1493,10 @@ mod tests {
// A corner handle scales.
let corner = h.apply(Point::new(rect.x1, rect.y1)).unwrap();
assert!(matches!(t.cursor(&c, corner, Mods::default()), Cursor::ResizeNwSe | Cursor::ResizeNeSw));
// In a view turned 90° its cursor turns with the view.
let mut turned = cx(&d, &s, &p);
turned.screen = turned_view(90.0);
assert_eq!(t.cursor(&turned, corner, Mods::default()), t.cursor(&c, corner, Mods::default()).turned(90.0));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale in Perspective".into())]);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, corner.x + 10.0, corner.y + 10.0));
assert!(matches!(&acts[0], Action::Preview(cmd, _) if cmd == "perspective.transform"));
+38
View File
@@ -353,6 +353,12 @@ impl ToolContext<'_> {
pub fn tol(&self, px: f64) -> f64 {
px / self.zoom.max(1e-9)
}
/// How far the view is turned (Rotate View), in degrees clockwise on screen; 0 without a
/// window.
pub fn view_angle(&self) -> f64 {
// `right` is one screen pixel to the right, in document units.
self.screen.map(|s| (-s.right.y).atan2(s.right.x).to_degrees()).filter(|a| a.is_finite()).unwrap_or(0.0)
}
/// How near (document units) a smart guide target pulls: Smart Guides → Snapping Tolerance.
pub fn snap_tol(&self) -> f64 {
self.tol(self.snapping_tolerance)
@@ -582,6 +588,31 @@ impl Cursor {
Cursor::RotateToward(octant.rem_euclid(8) as u8)
}
/// [`Cursor::rotate_about`] in a view turned `view` degrees clockwise
/// ([`ToolContext::view_angle`]): the pointer's side of `center` as it shows on screen. The
/// pointer's direction turns with the view before it rounds to the nearest 45°.
pub fn rotate_about_in_view(center: Point, p: Point, view: f64) -> Self {
let (sn, cs) = if view.is_finite() { view.to_radians().sin_cos() } else { (0.0, 1.0) };
let d = p - center;
// y points down the page, so this turns `d` clockwise on the page.
Cursor::rotate_about(center, center + Vec2::new(d.x * cs - d.y * sn, d.x * sn + d.y * cs))
}
/// The cursor as it shows in a view turned `deg` degrees clockwise (Rotate View): the resize
/// cursors and [`Cursor::RotateToward`] turn with the view to the nearest 45°, and every other
/// cursor stays as it is.
pub fn turned(self, deg: f64) -> Self {
const RESIZE: [Cursor; 4] = [Cursor::ResizeH, Cursor::ResizeNeSw, Cursor::ResizeV, Cursor::ResizeNwSe];
let k = if deg.is_finite() { (deg / 45.0).round() as i64 } else { 0 };
match self {
Cursor::RotateToward(o) => Cursor::RotateToward((i64::from(o) + k).rem_euclid(8) as u8),
c => match RESIZE.iter().position(|r| *r == c) {
Some(i) => RESIZE.get((i as i64 - k).rem_euclid(4) as usize).copied().unwrap_or(c),
None => c,
},
}
}
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
/// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
pub fn precise(self) -> Self {
@@ -834,6 +865,13 @@ pub(crate) mod testutil {
PaintDefaults::default()
}
/// An 800 × 600 window on a view at 100% turned `deg` degrees clockwise (Rotate View): at 90°
/// one pixel right on screen is one point up the page.
pub fn turned_view(deg: f64) -> Option<ScreenFrame> {
let (sn, cs) = deg.to_radians().sin_cos();
Some(ScreenFrame { origin: Point::ORIGIN, right: Vec2::new(cs, -sn), down: Vec2::new(sn, cs), size: (800.0, 600.0) })
}
pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> {
ToolContext {
doc: d,
+3 -3
View File
@@ -310,12 +310,12 @@ impl GuideEdit {
out
}
/// The pointer over a guide, or while one is dragged.
/// The pointer over a guide, or while one is dragged, turned with the view.
pub fn cursor(&self, cx: &ToolContext, p: Point) -> Option<Cursor> {
if let Some(d) = &self.drag {
return Some(guide_cursor(d.vertical));
return Some(guide_cursor(d.vertical).turned(cx.view_angle()));
}
guide_at(cx, p).and_then(|i| cx.doc.guides.get(i)).map(|g| guide_cursor(g.vertical))
guide_at(cx, p).and_then(|i| cx.doc.guides.get(i)).map(|g| guide_cursor(g.vertical).turned(cx.view_angle()))
}
/// While a guide is dragged: the smart guide it snapped to and its position by the pointer.
+67 -5
View File
@@ -560,8 +560,8 @@ impl Tool for SelectionTool {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
match self.state {
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
State::Rotating { center, .. } => return Cursor::rotate_about_in_view(center, p, cx.view_angle()),
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle - cx.view_angle()),
// Alt-dragging leaves a copy behind; otherwise the filled arrow, hollow while the move
// snaps to a point or a guide.
State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy,
@@ -590,8 +590,8 @@ impl Tool for SelectionTool {
&& let Some(bx) = selection_box(cx)
{
match box_hit(cx, &bx, p) {
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle),
Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p),
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle - cx.view_angle()),
Some(BoxHit::Rotate) => return Cursor::rotate_about_in_view(bx.center(), p, cx.view_angle()),
None => {}
}
}
@@ -664,7 +664,8 @@ pub(crate) fn release_guide_at(cx: &ToolContext, p: Point, m: Mods) -> Option<Ac
}
/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the
/// handle that sits where `h` appears on screen, in 45° steps.
/// handle that sits where `h` appears on screen, in 45° steps. In a turned view, `angle` is the
/// box's own angle less [`ToolContext::view_angle`].
fn handle_cursor(h: Handle, angle: f64) -> Cursor {
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h);
@@ -853,6 +854,67 @@ mod tests {
assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up");
}
#[test]
fn turned_cursors_step_with_the_view() {
assert_eq!(Cursor::ResizeH.turned(90.0), Cursor::ResizeV);
assert_eq!(Cursor::ResizeNeSw.turned(45.0), Cursor::ResizeH);
assert_eq!(Cursor::ResizeH.turned(45.0), Cursor::ResizeNwSe);
assert_eq!(Cursor::ResizeNwSe.turned(-135.0), Cursor::ResizeV);
assert_eq!(Cursor::RotateToward(0).turned(90.0), Cursor::RotateToward(2));
assert_eq!(Cursor::RotateToward(1).turned(-45.0), Cursor::RotateToward(0));
assert_eq!(Cursor::RotateToward(7).turned(180.0), Cursor::RotateToward(3));
assert_eq!(Cursor::ResizeV.turned(20.0), Cursor::ResizeV, "under 22.5° it stays");
assert_eq!(Cursor::Pen.turned(90.0), Cursor::Pen);
assert_eq!(Cursor::Rotate.turned(90.0), Cursor::Rotate);
assert_eq!(Cursor::ResizeH.turned(f64::NAN), Cursor::ResizeH);
// `rotate_about_in_view` turns the pointer's direction by the view before it rounds: a
// pointer 15° clockwise from straight up, in a view turned 15°, shows 30° from straight up
// on screen, which rounds to the top right. `turned` rounds the 15° view to 0°.
let (sn, cs) = 15f64.to_radians().sin_cos();
let q = Point::new(sn, -cs);
assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, q, 15.0), Cursor::RotateToward(1));
assert_eq!(Cursor::rotate_about(Point::ORIGIN, q).turned(15.0), Cursor::RotateToward(0));
assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, q, 0.0), Cursor::rotate_about(Point::ORIGIN, q));
assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::new(0.0, -5.0), 90.0), Cursor::RotateToward(2));
assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::new(0.0, -5.0), f64::NAN), Cursor::RotateToward(0));
assert_eq!(Cursor::rotate_about_in_view(Point::ORIGIN, Point::ORIGIN, 90.0), Cursor::Rotate, "on the center");
}
/// In a rotated view the resize and rotate cursors turn with the view.
#[test]
fn box_cursors_turn_with_the_view() {
let (mut d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
d.guides.push(vectorcraft_doc::Guide::new(false, 300.0));
let mut c = cx(&d, &s, &p);
c.screen = turned_view(90.0);
assert!((c.view_angle() - 90.0).abs() < 1e-9);
let t = SelectionTool::default();
// The box runs from (100, 100) to (200, 200). Its right side shows at the bottom, and its
// top shows on the right.
assert_eq!(t.cursor(&c, Point::new(200.0, 150.0), Mods::default()), Cursor::ResizeV, "right-middle");
assert_eq!(t.cursor(&c, Point::new(150.0, 100.0), Mods::default()), Cursor::ResizeH, "top-middle");
assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right");
// While a rotate drags, the cursor turns with the view too: straight up the page shows to
// the right.
let mut r = SelectionTool::default();
assert_eq!(r.pointer(&c, &ev(PointerKind::Down, 207.0, 93.0)), vec![Action::Begin("Rotate".into())]);
assert_eq!(r.cursor(&c, Point::new(150.0, 80.0), Mods::default()), Cursor::RotateToward(2), "rotating");
// A horizontal ruler guide shows upright and moves left and right on screen.
assert_eq!(t.cursor(&c, Point::new(400.0, 300.0), Mods::default()), Cursor::ResizeH, "guide");
// A box turned 20° in a view turned 25° shows 5° off level: the right handle shows the
// left-right arrow.
let (center, a) = (Point::new(150.0, 150.0), 20f64.to_radians());
let turn = Affine::translate(center.to_vec2()) * Affine::rotate(-a) * Affine::translate(-center.to_vec2());
d.node_mut(id).unwrap().transform(turn, false);
let mut c = cx(&d, &s, &p);
c.screen = turned_view(25.0);
let right = Point::new(150.0 + 50.0 * a.cos(), 150.0 - 50.0 * a.sin());
assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeH, "20° box, 25° view");
}
#[test]
fn drag_moves_with_single_undo() {
let (d, id) = doc_with_rect();
+5
View File
@@ -313,6 +313,7 @@ impl Tool for SliceSelectionTool {
Some(Handle::TopRight | Handle::BottomLeft) => Cursor::ResizeNeSw,
None => Cursor::SliceSelect,
}
.turned(cx.view_angle())
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
@@ -413,6 +414,10 @@ mod tests {
t.pointer(&c, &ev(PointerKind::Up, 350.0, 350.0));
// A corner handle of the selected user slice resizes it.
assert_eq!(t.cursor(&c, Point::new(60.0, 40.0), Mods::default()), Cursor::ResizeNwSe);
// In a view turned 90° its cursor turns with the view.
let mut turned = cx(&d, &sel, &p);
turned.screen = turned_view(90.0);
assert_eq!(t.cursor(&turned, Point::new(60.0, 40.0), Mods::default()), Cursor::ResizeNeSw);
assert!(t.pointer(&c, &ev(PointerKind::Down, 60.0, 40.0)).is_empty());
let r = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 50.0));
assert_eq!(r[0], Action::Begin("Resize Slice".into()));
+16 -1
View File
@@ -481,7 +481,8 @@ impl Tool for ArtboardTool {
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
};
}
.turned(cx.view_angle());
}
if cx.doc.artboards.iter().any(|a| a.rect.contains(p)) { Cursor::Move } else { Cursor::Crosshair }
}
@@ -647,6 +648,20 @@ mod tests {
assert_eq!(v["width"].as_f64(), Some(210.0));
}
/// In a view turned 90° the handles' cursors turn with the view.
#[test]
fn handle_cursors_turn_with_the_view() {
let d = two_boards();
let (s, p) = (Selection::default(), paint());
let t = ArtboardTool { active: 1, ..Default::default() };
let mut c = cx(&d, &s, &p);
let (top, right) = (Point::new(700.0, 0.0), Point::new(800.0, 100.0));
assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeV);
c.screen = turned_view(90.0);
assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeH);
assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeV);
}
/// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one
/// out again, a plain click on an unselected one selects just it, and Delete removes all the
/// selected ones (one artboard is always left).
+18 -2
View File
@@ -313,7 +313,7 @@ impl Tool for FreeTransformTool {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
Some(Op::Rotate) => Cursor::rotate_about(r.center(), p),
Some(Op::Rotate) => Cursor::rotate_about_in_view(r.center(), p, cx.view_angle()),
Some(Op::Move) => Cursor::Move,
Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH,
Some(Op::Shear(_)) => Cursor::ShearV,
@@ -322,7 +322,8 @@ impl Tool for FreeTransformTool {
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
},
}
.turned(cx.view_angle()),
Some(Op::Corners(..)) => Cursor::Crosshair,
None => Cursor::Arrow,
}
@@ -383,6 +384,21 @@ mod tests {
assert!(matches!(&a[1], Action::Preview(_, v) if v["matrix"][4] == 10.0 && v["matrix"][5] == 20.0));
}
/// In a view turned 90° the handles' cursors turn with the view.
#[test]
fn handle_cursors_turn_with_the_view() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let mut c = cx(&d, &s, &p);
c.screen = turned_view(90.0);
let t = FreeTransformTool::default();
assert_eq!(t.cursor(&c, Point::new(150.0, 100.0), Mods::default()), Cursor::ResizeH, "top-middle");
assert_eq!(t.cursor(&c, Point::new(200.0, 200.0), Mods::default()), Cursor::ResizeNeSw, "bottom-right");
assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right");
}
#[test]
fn cmd_side_handle_shears() {
let (d, id) = doc_with_rect();
+7 -2
View File
@@ -321,14 +321,14 @@ pub(super) fn key(cx: &ToolContext, a: &Annotator, lines: &mut Lines, key: ToolK
pub(super) fn cursor(cx: &ToolContext, a: &Annotator, g: Option<&Gesture>, p: Point) -> Cursor {
match g.filter(|g| g.began).map(|g| (&g.grab, g.out)) {
Some((Grab::Point(_), true)) => return Cursor::RemoveStop,
Some((Grab::Spread { .. }, _)) => return Cursor::ResizeH,
Some((Grab::Spread { .. }, _)) => return Cursor::ResizeH.turned(cx.view_angle()),
Some(_) => return Cursor::Move,
None => {}
}
if a.point_at(cx, p).is_some() {
Cursor::Move
} else if a.ring_at(cx, p).is_some() {
Cursor::ResizeH
Cursor::ResizeH.turned(cx.view_angle())
} else if a.line_at(cx, p).is_some() || on_art(cx, p) {
Cursor::AddStop
} else {
@@ -486,6 +486,11 @@ mod tests {
// Spread 0.2 of half the 100 pt box: a 10 pt ring around (150, 180), its handle on it.
assert_eq!(t.cursor(&cx, Point::new(150.0, 170.0), Default::default()), Cursor::ResizeH);
assert!(t.overlays(&cx).contains(&Overlay::Handle { p: Point::new(166.0, 180.0), color: LINE }), "at least 16 px out");
// In a view turned 90° the handle shows below the point and drags up and down on screen.
let mut turned = crate::testutil::cx(&d, &s, &p);
turned.freeform_point = Some(2);
turned.screen = turned_view(90.0);
assert_eq!(t.cursor(&turned, Point::new(166.0, 180.0), Default::default()), Cursor::ResizeV);
assert!(t.pointer(&cx, &ev(PointerKind::Down, 160.0, 180.0)).is_empty());
let spread = |a: &[Action]| match a.last() {
Some(Action::Preview(c, v)) if c == "paint.freeform.setPoint" && v["index"] == 2 => (v["spread"].as_f64().unwrap() * 1e4).round() / 1e4,
+1
View File
@@ -683,6 +683,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted
cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests_prefs.rs` cover #1269;
actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the tool cursor is painted only while the window has the focus. Over a window behind another app, or under the File › Open… sheet, the system pointer shows alone instead of next to the painted cursor (#2020).
In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°.
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502).
With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run.
The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels.