diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index c07aa9efc..02e5624b3 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1463,6 +1463,60 @@ fn pointer_gesture_forwards_backquote_on_each_event() { assert_eq!(rect["bounds"]["x"], 100.0, "{doc}"); } +/// The documented Rotate View calls reach the desktop control methods, preserving document +/// coordinates and modifiers, and a successful null reset remains a successful MCP response. +#[test] +fn rotate_view_calls_forward_to_desktop_control() { + use std::cell::RefCell; + use std::rc::Rc; + + struct Recording(Rc>>); + impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.0.borrow_mut().push((method.into(), params)); + Ok(Value::Null) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "desktop control contract".into() + } + } + + let seen = Rc::new(RefCell::new(Vec::new())); + let mut s = Server::new(Box::new(Recording(seen.clone()))); + let selected = call(&mut s, 1, "select_tool", json!({"tool": "rotateView"})); + let turned = call( + &mut s, + 2, + "pointer_gesture", + json!({"events": [ + {"kind": "down", "x": 300, "y": 150}, {"kind": "drag", "x": 200, "y": 250}, {"kind": "up", "x": 200, "y": 250} + ], "mods": {"shift": true}}), + ); + let reset = call(&mut s, 3, "command_run", json!({"id": "view.rotateReset", "params": {}})); + for reply in [&selected, &turned, &reset] { + assert_eq!(reply["isError"], false, "{reply}"); + } + assert_eq!(text_of(&reset), "null"); + assert_eq!( + *seen.borrow(), + vec![ + ("ui.tool.select".into(), json!({"tool": "rotateView"})), + ( + "ui.pointer".into(), + json!({"events": [ + {"kind": "down", "x": 300, "y": 150, "space": "doc"}, + {"kind": "drag", "x": 200, "y": 250, "space": "doc"}, + {"kind": "up", "x": 200, "y": 250, "space": "doc"} + ], "mods": {"shift": true}}) + ), + ("engine.execute".into(), json!({"command": "view.rotateReset", "params": {}})) + ] + ); +} + /// A `backquote` that is not a boolean is an error, and none of the gesture's events runs. #[test] fn pointer_gesture_rejects_a_backquote_that_is_not_a_boolean() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 81c601c1c..0f5152d2b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -126,6 +126,7 @@ impl VectorcraftApp { updates_rt: Default::default(), frame_rate: Default::default(), canvas_rect: None, + control_rotate_press: None, hover_doc: None, titlebar_from_env: false, custom_titlebar: false, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index d5ca8a196..9e698b1bf 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -404,6 +404,10 @@ impl VectorcraftApp { if let Err(e) = switched { self.ui.status = e.to_string(); } + if before != self.session.tool_id() { + // A control gesture cannot resume after another tool has owned the pointer. + self.control_rotate_press = None; + } // A newly chosen Artboard tool starts on the active artboard (the view's). if before != "artboard" && self.session.tool_id() == "artboard" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a784941eb..6fc9baab5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -77,6 +77,7 @@ use tool_overlays::{ print_tiling_overlay, rotate_view_dial, shaper_widget_overlay, slice_overlay, spelling_overlay, thread_overlays, }; pub(crate) use view::animated_zoom; +pub(crate) use view::drag_rotation; pub use view::fit; use view::scroll_bars; #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5fbc2b903..43e5df0bc 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -337,14 +337,8 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) } } Drag::RotateView { start_angle, start_rot, snaps } => { - let c = rect.center(); - let a = (p.y - c.y).atan2(p.x - c.x) as f64; - let mut deg = start_rot + (a - start_angle).to_degrees(); - if snaps && m.shift { - deg = (deg / 15.0).round() * 15.0; - } if let Some(vm) = app.view_mut() { - vm.rotation = vectorcraft_geom::normalize_deg(deg); + vm.rotation = super::view::drag_rotation(rect.center(), p, start_angle, start_rot, snaps && m.shift); } } Drag::ZoomBox { start } => { diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 558bf011d..60cb6687b 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -1,6 +1,16 @@ use super::drag::{drag_id, drop_press}; use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2}; +/// The mouse and control-channel Rotate View gestures use the same angle and Shift snapping. +pub(crate) fn drag_rotation(center: Pos2, point: Pos2, start_angle: f64, start_rotation: f64, snap: bool) -> f64 { + let angle = (point.y - center.y).atan2(point.x - center.x) as f64; + let mut degrees = start_rotation + (angle - start_angle).to_degrees(); + if snap { + degrees = (degrees / 15.0).round() * 15.0; + } + vectorcraft_geom::normalize_deg(degrees) +} + /// Fit the view (View → Fit Artboard / Fit All / Actual Size, Arrange Tools › Zoom to Selection). pub fn fit(app: &mut VectorcraftApp, how: &str) { let Some(rect) = app.canvas_rect else { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dce986df6..419d4ab90 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -610,10 +610,116 @@ pub(crate) fn save_screenshot_with_limit( #[cfg(test)] mod tests; +/// The window and coordinate mapping where a control-channel Rotate View drag began. +#[derive(Clone, Copy)] +pub(crate) struct RotateViewPress { + window: (u64, u32), + document_resets: u64, + xf: Xf, + start_angle: f64, + start_rotation: f64, +} + +/// Rotate View belongs to the canvas, not the engine's placeholder tool. Keep its press mapping +/// across requests so document-coordinate drags do not move again as the view turns underneath. +fn rotate_view_pointer( + app: &mut VectorcraftApp, + kind: PointerKind, + position: vectorcraft_geom::Point, + screen: bool, + shift: bool, +) -> Result<(), String> { + let window = app.session.active().zip(crate::doc_windows::current(app)).map(|(d, w)| (d.uid, w.1)).ok_or("no document")?; + let document_resets = app.session.tool_resets(); + let stored = if kind == PointerKind::Down { None } else { app.control_rotate_press }; + if kind == PointerKind::Drag && stored.is_none() { + return Err("Rotate View needs a down event before a drag".into()); + } + let mut press = match stored { + Some(press) => press, + None => { + let rect = app.canvas_rect.ok_or("no canvas yet")?; + let view = app.view().ok_or("no document")?; + RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + } + }; + // A gesture cannot turn a different document/view or resume after its document was reset. + if (press.window, press.document_resets) != (window, document_resets) { + return Err("the Rotate View gesture's window or document changed".into()); + } + let point = if screen { egui::pos2(position.x as f32, position.y as f32) } else { press.xf.to_screen(position) }; + let center = press.xf.rect.center(); + if !point.is_finite() || !center.is_finite() { + return Err("Rotate View needs finite canvas coordinates".into()); + } + match kind { + PointerKind::Down => { + press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64; + app.control_rotate_press = Some(press); + } + PointerKind::Drag => { + app.view_mut().ok_or("no document")?.rotation = + crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift); + } + PointerKind::Up => { + app.control_rotate_press = None; + } + PointerKind::Move | PointerKind::DoubleClick => {} + } + Ok(()) +} + +/// A Rotate View packet accepts only the documented boolean modifier fields. +fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> { + let Some(value) = value else { return Ok(()) }; + let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) }; + if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) { + return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into()); + } + Ok(()) +} + +/// Reject malformed coordinates and modifiers before any event can change the angle or press. +fn validate_rotate_view_events(params: &Value, events: &[Value]) -> Result<(), String> { + validate_rotate_view_mods(params.get("mods"))?; + for event in events { + for axis in ["x", "y"] { + if event.get(axis).and_then(Value::as_f64).filter(|value| value.is_finite()).is_none() { + return Err(format!("Rotate View `{axis}` must be a finite number")); + } + } + validate_rotate_view_mods(event.get("mods"))?; + if event.get("space").is_some_and(|space| !matches!(space.as_str(), Some("doc" | "screen"))) { + return Err("Rotate View `space` must be doc or screen".into()); + } + } + Ok(()) +} + /// `ui.pointer`: pointer events through the tools. fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let rotating = app.session.tool_id() == "rotateView"; + let before = if rotating { app.view().map(|view| view.rotation) } else { None }; + let outcome = pointer_events_inner(app, ctx, req); + if rotating && matches!(&outcome, Outcome::Done(reply) if reply.get("ok").and_then(Value::as_bool) == Some(false)) { + // A failed packet must neither leave a partial turn nor allow a later drag to resume it. + app.control_rotate_press = None; + if let (Some(before), Some(view)) = (before, app.view_mut()) { + view.rotation = before; + } + ctx.request_repaint(); + } + outcome +} + +/// Apply a pointer packet after Rotate View validation; other tools retain their existing routing. +fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let rotating = app.session.tool_id() == "rotateView"; + if rotating && let Err(error) = validate_rotate_view_events(p, events) { + return err(error); + } let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); @@ -636,6 +742,13 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); let screen = e.get("space").and_then(Value::as_str) == Some("screen"); + let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); + if rotating { + if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { + return err(e); + } + continue; + } let pos = if screen { match xf { Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), @@ -644,7 +757,6 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe } else { vectorcraft_geom::Point::new(x, y) }; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); // A tilt given in screen axes leans the same way on the page as on the screen. let pen = PointerEvent::json_pen(e).map(|q| match xf { Some(xf) if screen => crate::canvas::pose_on_page(q, &xf), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d6c91d3d1..932520b18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -187,6 +187,8 @@ pub struct VectorcraftApp { frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, + /// An unfinished control-channel Rotate View gesture, kept only for this app instance. + pub(crate) control_rotate_press: Option, /// Hover position in document coordinates. pub hover_doc: Option, /// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index 017d0175b..2f98a3f3b 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -30,6 +30,13 @@ fn with_document() -> VectorcraftApp { app } +/// Send a control request and keep its success/error value for protocol assertions. +fn control_response(app: &mut VectorcraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (request, _) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(reply) = crate::control::handle(app, ctx, &request) else { panic!("not done") }; + reply +} + /// Returns the labels of the View › Rotate View items that are checked. fn checked(app: &VectorcraftApp) -> Vec<&'static str> { ANGLES.iter().filter(|(_, a)| menus::checked(app, "view.rotate", &json!({ "angle": a })) == Some(true)).map(|(l, _)| *l).collect() @@ -55,6 +62,157 @@ fn view_rotate_turns_the_view_to_any_angle() { assert_eq!(empty.run("view.rotate", json!({"angle": 30})).unwrap_err(), "no document"); } +/// MCP's `pointer_gesture` reaches `ui.pointer`: view tools must turn the canvas there too, +/// rather than silently sending the gesture to the engine's no-op tool. +#[test] +fn control_rotate_view_gesture_turns_the_canvas_and_reset_restores_it() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + let revision = app.session.active().unwrap().revision; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let mut frame = ctx.run_ui(Default::default(), |_| {}); + frame.textures_delta.clear(); + send( + &mut app, + &ctx, + "ui.pointer", + json!({"events": [ + {"kind": "drag", "x": 200, "y": 250}, + {"kind": "up", "x": 200, "y": 250} + ]}), + ); + let view = app.view().unwrap(); + assert!((view.rotation - 90.0).abs() < 1e-3, "{}", view.rotation); + assert_eq!(view.center, vectorcraft_geom::Point::new(200.0, 150.0)); + assert_eq!(view.zoom, 1.0); + assert_eq!(app.session.active().unwrap().revision, revision, "navigation does not edit the document"); + let reset = control_response(&mut app, &ctx, "engine.execute", json!({"command": "view.rotateReset", "params": {}})); + assert_eq!(reset["ok"], true); + assert!(reset["result"].is_null()); + assert_eq!(app.view().unwrap().rotation, 0.0); +} + +/// Repeated document points stay fixed across requests, including Shift snapping from a turned view. +#[test] +fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_requests() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + view.rotation = 30.0; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let angle = 20_f64.to_radians(); + let (x, y) = (200.0 + 100.0 * angle.cos(), 150.0 + 100.0 * angle.sin()); + for _ in 0..2 { + send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]})); + assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes"); + } + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]})); + assert_eq!(app.view().unwrap().rotation, 45.0); +} + +/// Invalid coordinates and document changes cannot turn a different view. +#[test] +fn control_rotate_view_rejects_nonfinite_positions_and_a_changed_document() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": 1e300, "y": 0}]})); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.view().unwrap().rotation, 0.0); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + app.run("file.new", json!({})).unwrap(); + let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "drag", "x": 200, "y": 250}]})); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.view().unwrap().rotation, 0.0); +} + +/// An invalid packet leaves the angle unchanged and requires a fresh press, including errors +/// after an otherwise valid drag and malformed releases or hovers. +#[test] +fn control_rotate_view_rejects_malformed_packets_without_leaving_a_press() { + let drag = json!({"kind": "drag", "x": 200, "y": 250}); + for packet in [ + json!({"events": [drag.clone(), {"kind": "drag", "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": "200", "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"shift": "yes"}}]}), + json!({"mods": "shift", "events": [drag.clone()]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"unknown": true}}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "up", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "move", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "doubleclick", "x": 1e300, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "wiggle", "x": 200, "y": 250}]}), + json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "space": "window"}]}), + json!({"events": null}), + ] { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(200.0, 150.0); + view.zoom = 1.0; + super::synthetic::control(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + super::synthetic::control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]})); + let reply = control_response(&mut app, &ctx, "ui.pointer", packet.clone()); + assert_eq!(reply["ok"], false, "{packet}: {reply}"); + assert_eq!(app.view().unwrap().rotation, 0.0, "{packet}"); + let resume = control_response(&mut app, &ctx, "ui.pointer", json!({"events": [drag.clone()]})); + assert_eq!(resume["ok"], false, "a failed packet cancels its press: {packet}"); + assert_eq!(app.view().unwrap().rotation, 0.0); + } +} + +/// Screen-space gestures survive frames, stop on release or a tool change, and start afresh +/// from the current angle. A press at the pivot uses the same finite convention as the mouse. +#[test] +fn control_rotate_view_screen_events_require_a_live_press() { + let mut app = with_document(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let view = app.view_mut().unwrap(); + view.center = vectorcraft_geom::Point::new(1200.0, 900.0); + view.zoom = 2.0; + let send = super::synthetic::control; + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + let down = json!({"events": [{"kind": "down", "x": 500, "y": 300, "space": "screen"}]}); + let drag = json!({"events": [{"kind": "drag", "x": 400, "y": 400, "space": "screen"}]}); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false); + send(&mut app, &ctx, "ui.pointer", down.clone()); + let mut frame = ctx.run_ui(Default::default(), |_| {}); + frame.textures_delta.clear(); + send(&mut app, &ctx, "ui.pointer", drag.clone()); + assert!((app.view().unwrap().rotation - 90.0).abs() < 1e-3); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": 400, "y": 400, "space": "screen"}]})); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "release ends the press"); + send(&mut app, &ctx, "ui.pointer", down.clone()); + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "selection"})); + send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"})); + assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "a tool change ends the press"); + send(&mut app, &ctx, "ui.pointer", down); + send(&mut app, &ctx, "ui.pointer", drag); + assert!((app.view().unwrap().rotation.abs() - 180.0).abs() < 1e-3, "a new press starts at the current angle"); + let before = app.view().unwrap().rotation; + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 400, "y": 300, "space": "screen"}]})); + send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "drag", "x": 500, "y": 300, "space": "screen"}]})); + assert!(app.view().unwrap().rotation.is_finite()); + assert!((app.view().unwrap().rotation - before).abs() < 1e-3); +} + #[test] fn the_view_menu_lists_the_status_bar_angles_and_checks_the_current_one() { let mut app = with_document(); diff --git a/docs/mcp.md b/docs/mcp.md index a9b1f1cd8..467ef2d34 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4191,7 +4191,21 @@ every document they read except a native file and the native document that an SV `view.rotate {angle}` (desktop app) turns the view to `angle` degrees about the center of the canvas; a positive angle turns the art clockwise on screen. View › Rotate View and the status bar's angle menu run it. It returns `{angle}`, the -angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0. +angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0, returning `null` on success. + +Select the Rotate View Tool with `select_tool` and the tool id `rotateView`. Its `pointer_gesture` +drags turn the canvas about its center; Shift snaps to 15°. Document coordinates for a rotation +gesture use the view at the initial down event, including when the gesture spans several calls. +The control channel rejects malformed rotation packets without changing the angle and cancels +the current press. Start a new down event after an error. +Reset Rotate View is the command `view.rotateReset`: + +```json +{"name":"select_tool","arguments":{"tool":"rotateView"}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":150},{"kind":"drag","x":200,"y":250},{"kind":"up","x":200,"y":250}]}} +{"name":"command_run","arguments":{"id":"view.rotateReset","params":{}}} +``` + `view.rotateToSelection` (desktop app, View › Rotate View to Selection) turns the view about the center of the canvas to the angle the selected objects share (`selectionRotation` in `document.inspect`, 0 when their angles differ), so their bounding box is square to the window. It returns `{angle}`, and with nothing selected it returns "nothing selected".