diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index 85555f3f6..967733ce4 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -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. diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index b9bc200c0..b105a1562 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -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")); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 615abe9ee..c9a70e785 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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 { + 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, diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 814bc6272..e16c31581 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -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 { 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. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 72c169c49..9f5d4278c 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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 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(); diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index f1f31643f..f0be51f05 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -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 { @@ -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())); diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index a40f9d04b..ed5c06c52 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -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). diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 6b218c900..dea02d634 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -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(); diff --git a/crates/tools/src/xform/freeform.rs b/crates/tools/src/xform/freeform.rs index 395d70617..d2c04aa2a 100644 --- a/crates/tools/src/xform/freeform.rs +++ b/crates/tools/src/xform/freeform.rs @@ -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, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17a16499a..000d6a0b9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.