From 276eeacae60cb666a31eccb5edf47f60a4bc4f80 Mon Sep 17 00:00:00 2001 From: Victor <124060164+VL420@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:25:15 +0000 Subject: [PATCH] =?UTF-8?q?G7:=20View=20=E2=80=BA=20Rulers=20=E2=80=BA=20C?= =?UTF-8?q?hange=20to=20Global=20/=20Artboard=20Rulers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Rulers › Change to Global Rulers (Cmd+Alt+R) was a disabled stub: the rulers always counted from one document zero point. The reference app's public help describes two modes: artboard rulers, the default, count from the active artboard's top-left corner, and each artboard can have its own zero point; global rulers keep one zero point for the whole document whichever artboard is active. https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html `view.globalRulers {global?}` switches the mode (toggle by default), undoable and saved with the document (`rulers`). The menu item reads Change to Artboard Rulers while global rulers are on. A new document has artboard rulers; a file saved before the mode existed has no `rulers` key and opens with global rulers, so it measures as it did. With artboard rulers, dragging the ruler corner sets the active artboard's zero point, kept as an offset from its corner (`artboard_ruler_origins`, by artboard id) so it moves with the artboard, and double-clicking the corner resets it. The global zero point (`ruler_origin`) stays separate. `document.rulerOrigin`, `document.setRulerOrigin` and `document.resetRulerOrigin` take `artboard`; `Document::ruler_origin_for` is the zero point with an artboard active, falling back to the corner or the global one when a stored value is out of range. The rulers, the Transform, Properties and Control bar X/Y, the Info panel's X/Y (which ignored the zero point before), ruler-guide readouts and Shift ruler-tick snapping all count from that zero point: the view's active artboard reaches the tools through `ViewInfo` and `ToolContext::ruler_origin`. Command coordinates stay absolute. Tests: engine/tests/ruler_origin.rs (default mode, per-artboard origin following its artboard, global origin apart, toggle and undo, save and legacy files, out-of-range values), ui-egui/src/tests_ruler_origin.rs (position fields and corner drag on the active artboard, menu label and shortcut), tools/src/rulerguide.rs (guide readouts and tick snapping). Tests written for the single global zero point now turn global rulers on first. "Change to Artboard Rulers" is translated in every catalog. Docs: docs/ui-parity.md, docs/gaps.md (G7), docs/target-app-parity.md, docs/mcp.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U2JbUtM4MkzWLm9FgYiEsq --- crates/doc/src/lib.rs | 49 +++++++++ crates/engine/src/cmd/edit.rs | 109 ++++++++++++++++---- crates/engine/src/tooling.rs | 4 + crates/engine/tests/ruler_origin.rs | 120 ++++++++++++++++++++++- crates/tools/src/lib.rs | 4 + crates/tools/src/rulerguide.rs | 44 ++++++++- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/menus.rs | 10 +- crates/ui-egui/src/panels/info.rs | 6 +- crates/ui-egui/src/tests_ruler_origin.rs | 87 +++++++++++++++- docs/gaps.md | 3 +- docs/mcp.md | 11 +++ docs/target-app-parity.md | 4 +- docs/ui-parity.md | 3 +- 34 files changed, 453 insertions(+), 33 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cfeac0f76..d87082784 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -594,6 +594,23 @@ pub struct ImageBlob { pub proxy: Option>>, } +/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a +/// new document, or one origin for the whole document. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RulerMode { + #[default] + Artboard, + Global, +} + +impl RulerMode { + /// The mode of a document saved before the choice existed: global, as its rulers measured. + pub fn legacy() -> Self { + Self::Global + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { pub version: u32, @@ -607,6 +624,15 @@ pub struct Document { /// The rulers' zero point in document coordinates. Moving it changes measurements, never art. #[serde(default, skip_serializing_if = "skip::is_default")] pub ruler_origin: Point, + /// View › Rulers › Change to Global / Artboard Rulers: whether the rulers and position fields + /// measure from [`Self::ruler_origin`] (global) or from the active artboard's own origin. + /// Documents saved before the choice existed measured globally and open that way. + #[serde(default = "RulerMode::legacy")] + pub rulers: RulerMode, + /// Artboard rulers' zero point of each artboard (by [`Artboard::id`]), as an offset from its + /// top-left corner; an artboard not listed measures from that corner. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub artboard_ruler_origins: BTreeMap, #[serde(default)] pub color_mode: ColorMode, pub artboards: Vec, @@ -772,6 +798,27 @@ impl Document { if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO } } + /// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`] + /// with global rulers; with artboard rulers that artboard's top-left corner moved by its own + /// offset (the first artboard's when `index` is out of range, the global zero point when the + /// document has no artboard or the result is out of the supported range). + pub fn ruler_origin_for(&self, index: usize) -> Point { + if self.rulers == RulerMode::Global { + return self.ruler_origin_or_zero(); + } + let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() }; + let corner = Point::new(ab.rect.x0, ab.rect.y0); + let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO); + let origin = corner + offset; + if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { + origin + } else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD { + corner + } else { + Point::ZERO + } + } + /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). pub fn new(width: f64, height: f64) -> Self { Self::new_with_mode(width, height, ColorMode::Rgb) @@ -786,6 +833,8 @@ impl Document { template: false, units: Unit::Points, ruler_origin: Point::ZERO, + rulers: RulerMode::Artboard, + artboard_ruler_origins: BTreeMap::new(), color_mode: mode, artboards: vec![Artboard { id: 1, diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 943df0542..9c5bbd559 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, Node, NodeId, Unit}; +use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit}; use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; @@ -37,13 +37,21 @@ pub fn specs() -> Vec { has_doc, set_units ), - cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))), + cmd!( + query "document.rulerOrigin", + "Ruler Origin", + [], + None, + "{artboard?: index=0 (the active artboard, which artboard rulers measure from)} → {x, y} the rulers' zero point, in absolute document points", + has_doc, + ruler_origin + ), cmd!( "document.setRulerOrigin", "Ruler Origin", [], None, - "{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place", + "{x, y, artboard?: index=0} set the rulers' zero point in absolute document points: the document's with global rulers, that artboard's with artboard rulers (it moves with the artboard); art, guides and command coordinates stay in place", has_doc, set_ruler_origin ), @@ -52,10 +60,19 @@ pub fn specs() -> Vec { "Reset Ruler Origin", [], None, - "{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner", + "{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner (with artboard rulers, that artboard's own zero point)", has_doc, reset_ruler_origin ), + cmd!( + "view.globalRulers", + "Change to Global Rulers", + ["View", "Rulers"], + Some("Cmd+Alt+R"), + "{global?: bool (default: toggle)} global rulers measure the whole document from one zero point; artboard rulers (a new document's) from the active artboard's top-left corner, or the zero point set for it. Saved with the document → {global}", + has_doc, + global_rulers + ), cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo), cmd!( @@ -229,33 +246,91 @@ fn set_units(s: &mut Session, p: &Value) -> Result { ok() } +/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given. +fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { + let index = match p.get("artboard") { + None => 0, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?, + }; + if index >= s.doc()?.doc.artboards.len() { + return Err(bad(c, "index out of range")); + } + Ok(index) +} + +fn ruler_origin(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + // A document without artboards measures from its global zero point. + let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; + Ok(json!(s.doc()?.doc.ruler_origin_for(index))) +} + fn set_ruler_origin(s: &mut Session, p: &Value) -> Result { const C: &str = "document.setRulerOrigin"; let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - change_ruler_origin(s, origin, "Ruler Origin") + let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? }; + change_ruler_origin(s, index, Some(origin), "Ruler Origin") } fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result { - const C: &str = "document.resetRulerOrigin"; - let index = match p.get("artboard") { - None => 0, - Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?, - }; - let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?; - change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin") + let index = artboard_param(s, p, "document.resetRulerOrigin")?; + change_ruler_origin(s, index, None, "Reset Ruler Origin") } -fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result { - if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD { +/// Move the rulers' zero point to `origin`, or back to artboard `index`'s top-left corner: the +/// document's (global rulers) or that artboard's (artboard rulers, kept as an offset from its +/// corner so it follows the artboard). +fn change_ruler_origin(s: &mut Session, index: usize, origin: Option, label: &str) -> Result { + let d = &s.doc()?.doc; + let global = d.rulers == RulerMode::Global; + let corner = d.artboards.get(index).map(|ab| Point::new(ab.rect.x0, ab.rect.y0)); + let target = origin.or(corner).unwrap_or(Point::ZERO); + if !target.is_finite() || target.x.abs() > crate::MAX_COORD || target.y.abs() > crate::MAX_COORD { return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())); } - if s.doc()?.doc.ruler_origin != origin { + if global { + if d.ruler_origin != target { + s.edit(label, |d, _| { + d.ruler_origin = target; + Ok(()) + })?; + } + return Ok(json!(target)); + } + let Some((id, corner)) = d.artboards.get(index).map(|ab| ab.id).zip(corner) else { + return Err(bad("document.setRulerOrigin", "index out of range")); + }; + let offset = Some(target - corner).filter(|o| *o != Vec2::ZERO); + if d.artboard_ruler_origins.get(&id).copied() != offset { s.edit(label, |d, _| { - d.ruler_origin = origin; + // Origins of artboards that no longer exist go too. + let ids: BTreeSet = d.artboards.iter().map(|ab| ab.id).collect(); + d.artboard_ruler_origins.retain(|id, _| ids.contains(id)); + match offset { + Some(o) => d.artboard_ruler_origins.insert(id, o), + None => d.artboard_ruler_origins.remove(&id), + }; Ok(()) })?; } - Ok(json!(origin)) + Ok(json!(target)) +} + +/// View › Rulers › Change to Global / Artboard Rulers. +fn global_rulers(s: &mut Session, p: &Value) -> Result { + let current = s.doc()?.doc.rulers == RulerMode::Global; + let global = match p.get("global") { + None => !current, + Some(v) => v.as_bool().ok_or_else(|| bad("view.globalRulers", "global must be true or false"))?, + }; + if global != current { + let label = if global { "Change to Global Rulers" } else { "Change to Artboard Rulers" }; + s.edit(label, |d, _| { + d.rulers = if global { RulerMode::Global } else { RulerMode::Artboard }; + Ok(()) + })?; + } + Ok(json!({ "global": global })) } /// A typing session in progress (the Type tool previews the whole session as one interaction). diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index b8609689f..db9861fe2 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -25,6 +25,8 @@ pub struct ViewInfo { pub corner_widgets: bool, /// The document window (none headless): screen-fixed widgets sit in it. pub screen: Option, + /// The view's active artboard (an index): artboard rulers measure from it. + pub artboard: usize, } impl Default for ViewInfo { @@ -40,6 +42,7 @@ impl Default for ViewInfo { snap_to_point: true, corner_widgets: true, screen: None, + artboard: 0, } } } @@ -184,6 +187,7 @@ impl Session { let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit()); let cx = ToolContext { doc: &st.doc, + ruler_origin: st.doc.ruler_origin_for(view.artboard), revision: (st.uid, st.revision), selection: &st.selection, zoom: view.zoom, diff --git a/crates/engine/tests/ruler_origin.rs b/crates/engine/tests/ruler_origin.rs index 446022aee..353f0ccab 100644 --- a/crates/engine/tests/ruler_origin.rs +++ b/crates/engine/tests/ruler_origin.rs @@ -2,6 +2,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; +use vectorcraft_doc::RulerMode; use vectorcraft_engine::Session; use vectorcraft_format::{SaveOptions, load, save, save_with}; @@ -29,7 +30,7 @@ fn changing_origin_preserves_artwork_and_selection_and_is_one_undo_step() { assert_eq!(origin(&mut s), p); assert_eq!(s.doc().unwrap().selection, selection); let mut changed = s.execute("document.json", &json!({})).unwrap(); - changed.as_object_mut().unwrap().remove("ruler_origin"); + changed.as_object_mut().unwrap().remove("artboard_ruler_origins"); assert_eq!(changed, serde_json::to_value(&*before).unwrap(), "only the origin changed"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); let revision = s.doc().unwrap().revision; @@ -45,6 +46,8 @@ fn changing_origin_preserves_artwork_and_selection_and_is_one_undo_step() { #[test] fn reset_uses_the_requested_artboards_top_left_and_ignores_noops() { let mut s = session(); + // The one document zero point of global rulers (artboard rulers keep one per artboard). + s.execute("view.globalRulers", &json!({"global": true})).unwrap(); s.execute("artboard.new", &json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap(); s.execute("document.setRulerOrigin", &json!({"x": 25, "y": 50})).unwrap(); let boards = s.doc().unwrap().doc.artboards.clone(); @@ -73,7 +76,8 @@ fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero for (version, compress) in [(1, false), (2, false), (3, false), (4, false), (4, true)] { let bytes = save_with(doc, &SaveOptions { version, compress, ..Default::default() }).unwrap(); let back = load(&bytes).unwrap(); - assert_eq!(serde_json::to_value(&back).unwrap()["ruler_origin"], p, "v{version}, compression {compress}"); + assert_eq!(back.ruler_origin_for(0), doc.ruler_origin_for(0), "v{version}, compression {compress}"); + assert_eq!((back.rulers, &back.artboard_ruler_origins), (doc.rulers, &doc.artboard_ruler_origins), "v{version}, compression {compress}"); assert_eq!(back.layers, doc.layers); assert_eq!(back.guides, doc.guides); } @@ -97,3 +101,115 @@ fn origin_rejects_invalid_coordinates_without_changing_absolute_command_coordina assert_eq!((bounds.x0, bounds.y0), (80.0, 40.0), "engine coordinates stay absolute"); assert_eq!(origin(&mut s), json!({"x": 200.0, "y": 150.0})); } + +// View › Rulers › Change to Global / Artboard Rulers. Behaviour from Adobe's public help: +// https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html +// (global rulers count from the first artboard's top-left corner and stay there; artboard rulers, +// the default, from the active artboard's, and each artboard can have its own origin) and +// https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html +// (View > Rulers > Change to Artboard Rulers / Change to Global Rulers; double-click the corner +// where the rulers meet to restore the default origin). + +fn two_boards() -> Session { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap(); + s +} + +fn origin_of(s: &mut Session, artboard: usize) -> Value { + s.execute("document.rulerOrigin", &json!({"artboard": artboard})).unwrap() +} + +#[test] +fn new_documents_use_artboard_rulers_that_count_from_the_active_artboard() { + let mut s = two_boards(); + assert_eq!(s.doc().unwrap().doc.rulers, RulerMode::Artboard); + assert_eq!(origin_of(&mut s, 0), json!({"x": 0.0, "y": 0.0})); + assert_eq!(origin_of(&mut s, 1), json!({"x": 700.0, "y": -250.0}), "zero at the second artboard's top-left corner"); + // Moving an artboard moves its zero point with it. + s.execute("artboard.setProps", &json!({"index": 1, "x": 650, "y": -200})).unwrap(); + assert_eq!(origin_of(&mut s, 1), json!({"x": 650.0, "y": -200.0})); + assert!(s.execute("document.rulerOrigin", &json!({"artboard": 2})).is_err()); +} + +#[test] +fn global_rulers_count_from_one_zero_point_whichever_artboard_is_active() { + let mut s = two_boards(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("view.globalRulers", &json!({})).unwrap(), json!({"global": true})); + assert_eq!(s.doc().unwrap().doc.rulers, RulerMode::Global); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + for i in [0, 1] { + assert_eq!(origin_of(&mut s, i), json!({"x": 0.0, "y": 0.0}), "artboard {i}"); + } + // Setting it explicitly to what it is is a no-op; a bad value changes nothing. + s.execute("view.globalRulers", &json!({"global": true})).unwrap(); + assert!(s.execute("view.globalRulers", &json!({"global": "yes"})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.rulers, RulerMode::Artboard); + assert_eq!(origin_of(&mut s, 1), json!({"x": 700.0, "y": -250.0})); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.execute("view.globalRulers", &json!({})).unwrap(), json!({"global": false}), "toggles back"); +} + +#[test] +fn each_artboard_keeps_its_own_origin_apart_from_the_global_one() { + let mut s = two_boards(); + s.execute("document.setRulerOrigin", &json!({"x": 720, "y": -230, "artboard": 1})).unwrap(); + assert_eq!(origin_of(&mut s, 1), json!({"x": 720.0, "y": -230.0})); + assert_eq!(origin_of(&mut s, 0), json!({"x": 0.0, "y": 0.0}), "the other artboard keeps its corner"); + // The origin is kept relative to its artboard: it follows a move. + s.execute("artboard.setProps", &json!({"index": 1, "x": 600, "y": -250})).unwrap(); + assert_eq!(origin_of(&mut s, 1), json!({"x": 620.0, "y": -230.0})); + // Global rulers have their own zero point, untouched by the artboard's. + s.execute("view.globalRulers", &json!({"global": true})).unwrap(); + s.execute("document.setRulerOrigin", &json!({"x": 10, "y": 20})).unwrap(); + assert_eq!(origin_of(&mut s, 1), json!({"x": 10.0, "y": 20.0})); + s.execute("view.globalRulers", &json!({"global": false})).unwrap(); + assert_eq!(origin_of(&mut s, 1), json!({"x": 620.0, "y": -230.0})); + assert_eq!(origin_of(&mut s, 0), json!({"x": 0.0, "y": 0.0})); + // Double-clicking the corner restores that artboard's top-left corner, in one undo step. + let undo = s.doc().unwrap().history.undo.len(); + s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap(); + assert_eq!(origin_of(&mut s, 1), json!({"x": 600.0, "y": -250.0})); + assert!(s.doc().unwrap().doc.artboard_ruler_origins.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "already there: no-op"); + assert!(s.execute("document.setRulerOrigin", &json!({"x": 1, "y": 1, "artboard": 5})).is_err()); +} + +#[test] +fn the_ruler_mode_is_saved_and_files_from_before_it_measure_globally() { + let mut s = two_boards(); + s.execute("document.setRulerOrigin", &json!({"x": 750, "y": -200, "artboard": 1})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let back = load(&save(&doc, false)).unwrap(); + assert_eq!((back.rulers, back.ruler_origin_for(1)), (RulerMode::Artboard, doc.ruler_origin_for(1))); + s.execute("view.globalRulers", &json!({"global": true})).unwrap(); + let back = load(&save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(back.rulers, RulerMode::Global); + // A file written before the choice existed has no `rulers` key: its rulers measured globally. + let mut old = serde_json::to_value(&*doc).unwrap(); + old.as_object_mut().unwrap().remove("rulers"); + let old: vectorcraft_doc::Document = serde_json::from_value(old).unwrap(); + assert_eq!(old.rulers, RulerMode::Global); + assert_eq!(old.ruler_origin_for(1), old.ruler_origin_or_zero()); +} + +#[test] +fn stored_origins_out_of_range_fall_back_to_the_artboard_corner() { + let mut s = two_boards(); + let mut doc = s.doc().unwrap().doc.clone(); + let id = doc.artboards[1].id; + for bad in [vectorcraft_geom::Vec2::new(f64::NAN, 0.0), vectorcraft_geom::Vec2::new(0.0, 9.0e6)] { + std::sync::Arc::make_mut(&mut doc).artboard_ruler_origins.insert(id, bad); + assert_eq!(doc.ruler_origin_for(1), vectorcraft_geom::Point::new(700.0, -250.0)); + } + // An index past the artboards uses the first one; no artboard at all, the global zero point. + assert_eq!(doc.ruler_origin_for(9), vectorcraft_geom::Point::ZERO); + std::sync::Arc::make_mut(&mut doc).artboards.clear(); + assert_eq!(doc.ruler_origin_for(0), doc.ruler_origin_or_zero()); + let _ = origin_of(&mut s, 0); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c9a70e785..ee008f09a 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -225,6 +225,9 @@ impl ScreenFrame { /// Read-only context a tool sees. pub struct ToolContext<'a> { pub doc: &'a Document, + /// The rulers' zero point with the view's active artboard (see + /// [`Document::ruler_origin_for`]): where ruler ticks and measured positions count from. + pub ruler_origin: Point, /// Which open document `doc` is (its process-unique id) and its revision, which moves with /// every change of the document or the selection: what a tool keeps between pointer events /// for the document as it is (the snap targets of hovering) is keyed on it. @@ -875,6 +878,7 @@ pub(crate) mod testutil { pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> { ToolContext { doc: d, + ruler_origin: d.ruler_origin_for(0), revision: (0, 0), selection: s, zoom: 1.0, diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index e16c31581..1f9bc8e47 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -125,7 +125,7 @@ impl GuideSnap { fn snap(&mut self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> f64 { self.snapped.clear(); if shift { - let origin = cx.doc.ruler_origin_or_zero(); + let origin = cx.ruler_origin; let origin = if vertical { origin.x } else { origin.y }; return origin + cx.unit.snap_to_ruler_tick(v - origin, cx.zoom); } @@ -145,7 +145,7 @@ impl GuideSnap { fn overlays(&self, cx: &ToolContext, vertical: bool, at: f64, pointer: Point) -> Vec { let mut o = self.snapped.clone(); if cx.measurement_labels { - let origin = cx.doc.ruler_origin_or_zero(); + let origin = cx.ruler_origin; let (axis, zero) = if vertical { ("X", origin.x) } else { ("Y", origin.y) }; o.push(Overlay::Measure { p: pointer, text: format!("{axis}: {}", cx.len(at - zero)) }); } @@ -688,6 +688,7 @@ mod tests { for (unit, zoom, origin, pointer, expected, tick) in origin_tick_cases() { let (mut doc, _) = doc_with_rect(); doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; let (selection, paint) = (Selection::default(), paint()); let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for vertical in [false, true] { @@ -713,6 +714,7 @@ mod tests { for (unit, zoom, origin, pointer, expected, _) in origin_tick_cases() { let mut doc = guides_doc(); doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); @@ -753,6 +755,7 @@ mod tests { ] { let mut doc = guides_doc(); doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; let paint = paint(); for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { let mut selection = Selection::default(); @@ -776,6 +779,7 @@ mod tests { for origin in [Point::ZERO, Point::new(17.0, 23.0), Point::new(-17.0, -23.0)] { let mut doc = guides_doc(); doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); @@ -814,6 +818,7 @@ mod tests { ] { let mut doc = guides_doc(); doc.ruler_origin = origin; + doc.rulers = vectorcraft_doc::RulerMode::Global; let paint = paint(); for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { @@ -848,4 +853,39 @@ mod tests { } } } + + #[test] + fn ruler_guide_readouts_count_from_the_active_artboard_with_artboard_rulers() { + let mut doc = guides_doc(); + doc.artboards.push(vectorcraft_doc::Artboard { + id: 2, + name: "Artboard 2".into(), + rect: Rect::new(150.0, 200.0, 350.0, 400.0), + show_center_mark: false, + show_cross_hairs: false, + }); + doc.ruler_origin = Point::new(5.0, 5.0); + let (paint, selection) = (paint(), Selection::default()); + for (rulers, active, zero) in [ + (vectorcraft_doc::RulerMode::Artboard, 1, Point::new(150.0, 200.0)), + (vectorcraft_doc::RulerMode::Artboard, 0, Point::new(doc.artboards[0].rect.x0, doc.artboards[0].rect.y0)), + (vectorcraft_doc::RulerMode::Global, 1, Point::new(5.0, 5.0)), + ] { + doc.rulers = rulers; + let c = + ToolContext { ruler_origin: doc.ruler_origin_for(active), smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + assert_eq!(c.ruler_origin, zero, "{rulers:?} {active}"); + for vertical in [true, false] { + let mut new = NewGuide::new(&c, vertical, None); + new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); + let want = if vertical { format!("X: {}", c.len(222.0 - zero.x)) } else { format!("Y: {}", c.len(238.0 - zero.y)) }; + assert!(matches!(new.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == &want), "{rulers:?} {active}: want {want}"); + // Shift puts the guide on a ruler tick counted from the same zero point. + let snapped = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods { shift: true, ..Default::default() }), true); + let at = guide_preview_position(&snapped, vertical, None); + let from = if vertical { zero.x } else { zero.y }; + assert_eq!(at, from + c.unit.snap_to_ruler_tick(if vertical { 222.0 } else { 238.0 } - from, c.zoom), "{rulers:?} {active}"); + } + } + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cbfa68d91..b84afa76a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1523,7 +1523,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui ui.data_mut(|d| d.remove::<(Option, bool)>(drag_id)); if !cancelled && let Some(at) = at - && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y})) + && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) { app.status(e.to_string()); } @@ -6347,7 +6347,7 @@ mod tests { frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); check(&app, alt, release); let st = app.session.active().unwrap(); - assert_eq!(st.doc.ruler_origin, origin); + assert_eq!(st.doc.ruler_origin_for(0), origin); assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork"); assert_eq!(st.selection, selection); assert_eq!(history(&app), (original_history.0 + 1, 0, true)); @@ -6376,6 +6376,8 @@ mod tests { fn ruler_guide_origin_display_fallback_accepts_only_bounded_finite_pairs() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({})).unwrap(); + // The global zero point as stored (artboard rulers check their own, see `ruler_origin_for`). + app.run("view.globalRulers", json!({"global": true})).unwrap(); let max = vectorcraft_engine::MAX_COORD; for (origin, expected) in [ (Point::ZERO, Point::ZERO), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f49e0373b..2ee2037ef 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -344,6 +344,7 @@ Change Case Промяна на регистъра Change Screen Mode (F) Промяна на режима на екрана (F) Change to Global Rulers Превключване към глобални линии + Change to Artboard Rulers Превключване към линии на работната област Change to Right-to-Left Layout Превключване към подредба отдясно наляво Changed since the last Refresh. Променено след последното обновяване. Changes allowed: Позволени промени: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1b606dbf1..6d09c4a5d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -524,6 +524,7 @@ Show Rulers Zobrazit pravítka Hide Rulers Skrýt pravítka Change to Global Rulers Přepnout na globální pravítka + Change to Artboard Rulers Přepnout na pravítka pracovní plochy Show Video Rulers Zobrazit video pravítka Hide Bounding Box Skrýt ohraničovací rámeček Show Bounding Box Zobrazit ohraničovací rámeček diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e17e32848..cdc2c5c8b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -339,6 +339,7 @@ Change Case Groß-/Kleinschreibung ändern Change Screen Mode (F) Bildschirmmodus ändern (F) Change to Global Rulers In globale Lineale ändern + Change to Artboard Rulers In Zeichenflächenlineale ändern Change to Right-to-Left Layout In Layout von rechts nach links ändern Changed since the last Refresh. Seit der letzten Aktualisierung geändert. Changes allowed: Zulässige Änderungen: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7b1e32278..25f4884f0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -332,6 +332,7 @@ Change Case Αλλαγή πεζών/κεφαλαίων Change Screen Mode (F) Αλλαγή λειτουργίας οθόνης (F) Change to Global Rulers Χρήση καθολικών χαράκων + Change to Artboard Rulers Χρήση χαράκων πίνακα σχεδίασης Change to Right-to-Left Layout Μετάβαση σε διάταξη από δεξιά προς τα αριστερά Changed since the last Refresh. Τροποποιήθηκε μετά την τελευταία ανανέωση. Changes allowed: Επιτρεπόμενες αλλαγές: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7e166b82f..710c81f83 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -357,6 +357,7 @@ Change Case Cambiar mayúsculas/minúsculas Change Screen Mode (F) Cambiar modo de pantalla (F) Change to Global Rulers Cambiar a reglas globales + Change to Artboard Rulers Cambiar a reglas de mesa de trabajo Change to Right-to-Left Layout Cambiar a diseño de derecha a izquierda Changed since the last Refresh. Cambió desde la última actualización. Changes allowed: Cambios permitidos: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f66c35932..5b6c102d6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -517,6 +517,7 @@ Show Rulers Näytä viivaimet Hide Rulers Piilota viivaimet Change to Global Rulers Vaihda yleisiin viivaimiin + Change to Artboard Rulers Vaihda piirtoalustan viivaimiin Show Video Rulers Näytä videoviivaimet Hide Bounding Box Piilota rajausruutu Show Bounding Box Näytä rajausruutu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 08543af71..03a3539cd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -358,6 +358,7 @@ Change Case Modifier la casse Change Screen Mode (F) Changer de mode d’affichage (F) Change to Global Rulers Passer aux règles globales + Change to Artboard Rulers Passer aux règles du plan de travail Change to Right-to-Left Layout Passer à la disposition de droite à gauche Changed since the last Refresh. Modifié depuis la dernière actualisation. Changes allowed: Modifications autorisées : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ae91093f2..7469119a3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -510,6 +510,7 @@ Change Panel Layout Panelelrendezés módosítása Change Screen Mode (F) Képernyőmód váltása (F) Change to Global Rulers Váltás globális vonalzókra + Change to Artboard Rulers Váltás rajztábla-vonalzókra Change to Left-to-Right Layout Váltás balról jobbra elrendezésre Change to Right-to-Left Layout Váltás jobbról balra elrendezésre Changed since the last Refresh. A legutóbbi frissítés óta módosult. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8794550ee..d15dc33c1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -357,6 +357,7 @@ Change Case Maiuscole/minuscole Change Screen Mode (F) Cambia modalità schermo (F) Change to Global Rulers Passa a righelli globali + Change to Artboard Rulers Passa a righelli della tavola da disegno Change to Right-to-Left Layout Passa al layout da destra a sinistra Changed since the last Refresh. Modificato dall'ultimo aggiornamento. Changes allowed: Modifiche consentite: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 814d5cfbc..bee25dbd3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -510,6 +510,7 @@ Rulers 定規 Show Rulers 定規を表示 Change to Global Rulers グローバル定規に変更 + Change to Artboard Rulers アートボード定規に変更 Show Video Rulers ビデオ定規を表示 Hide Bounding Box バウンディングボックスを隠す Show Transparency Grid 透明グリッドを表示 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5435a5220..3acd460c2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -508,6 +508,7 @@ Rulers 눈금자 Show Rulers 눈금자 표시 Change to Global Rulers 전체 눈금자로 변경 + Change to Artboard Rulers 아트보드 눈금자로 변경 Show Video Rulers 비디오 눈금자 표시 Hide Bounding Box 경계 상자 숨기기 Show Transparency Grid 투명도 격자 표시 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 964a12199..9aeee8b4a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -482,6 +482,7 @@ Show Rulers Pokaż linijki Hide Rulers Ukryj linijki Change to Global Rulers Zmień na linijki globalne + Change to Artboard Rulers Zmień na linijki obszaru kompozycji Show Video Rulers Pokaż linijki wideo Hide Bounding Box Ukryj obwiednię Show Bounding Box Pokaż obwiednię diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1c7ab2b44..3a39b0c7c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -335,6 +335,7 @@ Change All Alterar Tudo Change Case Alterar Maiúsculas/Minúsculas Change to Global Rulers Mudar para Réguas Globais + Change to Artboard Rulers Mudar para Réguas da Prancheta Change to Right-to-Left Layout Mudar para Layout da Direita para a Esquerda Changed since the last Refresh. Alterado desde a última Atualização. Changes allowed: Alterações permitidas: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 569d8523c..711727207 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -355,6 +355,7 @@ Change Case Изменить регистр Change Screen Mode (F) Сменить режим экрана (F) Change to Global Rulers Переключить на глобальные линейки + Change to Artboard Rulers Переключить на линейки монтажной области Change to Right-to-Left Layout Переключить на макет справа налево Changed since the last Refresh. Изменено с момента последнего обновления. Changes allowed: Разрешённые изменения: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 602df2611..95a77aec4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -346,6 +346,7 @@ Change Case Ändra skiftläge Change Screen Mode (F) Ändra skärmläge (F) Change to Global Rulers Byt till globala linjaler + Change to Artboard Rulers Byt till ritytelinjaler Change to Right-to-Left Layout Byt till layout höger till vänster Changed since the last Refresh. Ändrad sedan senaste uppdateringen. Changes allowed: Tillåtna ändringar: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a0b0be3c4..adcb231a0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -351,6 +351,7 @@ Change Case Змінити регістр Change Screen Mode (F) Змінити режим екрана (F) Change to Global Rulers Переключити на глобальні лінійки + Change to Artboard Rulers Переключити на лінійки монтажної області Change to Right-to-Left Layout Переключити на макет праворуч наліво Changed since the last Refresh. Змінено з моменту останнього оновлення. Changes allowed: Дозволені зміни: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bd71a9633..696b6dfe8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -425,6 +425,7 @@ Change Panel Layout Thay đổi bố cục bảng Change Screen Mode (F) Thay đổi chế độ màn hình (F) Change to Global Rulers Chuyển sang thước toàn cục + Change to Artboard Rulers Chuyển sang thước bảng vẽ Change to Left-to-Right Layout Đổi sang bố cục trái sang phải Change to Right-to-Left Layout Chuyển sang bố cục phải sang trái Changed since the last Refresh. Đã thay đổi kể từ lần làm mới cuối. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 96d26f3c4..d5bbc0ba0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -314,6 +314,7 @@ Change Case 更改大小写 Change Screen Mode (F) 更改屏幕模式 (F) Change to Global Rulers 更改为全局标尺 + Change to Artboard Rulers 更改为画板标尺 Change to Right-to-Left Layout 更改为由右至左版面配置 Changed since the last Refresh. 自上次刷新后已更改 Changes allowed: 允许的更改: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b85fdfc75..b78d7dafd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -326,6 +326,7 @@ Change Case 變更大小寫 Change Screen Mode (F) 變更螢幕模式 (F) Change to Global Rulers 變更為全域尺標 + Change to Artboard Rulers 變更為工作區域尺標 Change to Right-to-Left Layout 變更為由右至左版面配置 Changed since the last Refresh. 自上次重新整理後已變更。 Changes allowed: 允許的變更: diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82c429b90..487e95984 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -630,9 +630,11 @@ impl VectorcraftApp { self.session.active_index().and_then(|i| self.views.get(i)) } - /// The zero point shown by rulers and position fields; command coordinates stay absolute. + /// The zero point shown by rulers and position fields: the document's with global rulers, the + /// active artboard's with artboard rulers. Command coordinates stay absolute. pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point { - self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin_or_zero()) + let artboard = self.view().map_or(0, |v| v.artboard); + self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin_for(artboard)) } pub fn view_mut(&mut self) -> Option<&mut View> { self.sync_views(); @@ -654,6 +656,7 @@ impl VectorcraftApp { snap_to_point: self.ui.view.snap_to_point, corner_widgets: self.ui.view.corner_widgets && self.ui.view.edges, screen: self.screen_frame(), + artboard: self.view().map_or(0, |v| v.artboard), } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5267fa263..788e58248 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1816,6 +1816,12 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), + "view.globalRulers" => if app.session.active().is_some_and(|d| d.doc.rulers == vectorcraft_doc::RulerMode::Global) { + "Change to Artboard Rulers" + } else { + "Change to Global Rulers" + } + .into(), "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), "view.transparencyGrid" => { if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into() @@ -2803,7 +2809,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Lock Slices", "view.slices.lock"), c("Hide Template", "view.template"), Sep, - sub("Rulers", vec![c("Show Rulers", "view.rulers"), todos("Change to Global Rulers", "Cmd+Alt+R"), todo("Show Video Rulers")]), + sub("Rulers", vec![c("Show Rulers", "view.rulers"), c("Change to Global Rulers", "view.globalRulers"), todo("Show Video Rulers")]), c("Hide Bounding Box", "view.boundingBox"), c("Show Transparency Grid", "view.transparencyGrid"), c("Hide Text Threads", "view.textThreads"), @@ -3879,6 +3885,8 @@ pub const DYNAMIC_LABELS: &[&str] = &[ "Show Artboards", "Hide Rulers", "Show Rulers", + "Change to Global Rulers", + "Change to Artboard Rulers", "Hide Bounding Box", "Show Bounding Box", "Hide Transparency Grid", diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index d2d79f135..aa8f187f6 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -48,13 +48,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let units = app.session.general_unit(); let p = app.hover_doc.unwrap_or_default(); + // X and Y count from the rulers' zero point, as the rulers and the Transform panel do. + let origin = app.ruler_origin(); let bounds = app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)); let mono = |ui: &mut Ui, s: String| { ui.label(egui::RichText::new(s).monospace().color(t.text)); }; ui.columns(2, |cols| { - mono(&mut cols[0], format!("X: {}", units.format(p.x))); - mono(&mut cols[0], format!("Y: {}", units.format(p.y))); + mono(&mut cols[0], format!("X: {}", units.format(p.x - origin.x))); + mono(&mut cols[0], format!("Y: {}", units.format(p.y - origin.y))); let (w, h) = bounds.map(|b| (units.format(b.width()), units.format(b.height()))).unwrap_or_default(); mono(&mut cols[1], format!("W: {w}")); mono(&mut cols[1], format!("H: {h}")); diff --git a/crates/ui-egui/src/tests_ruler_origin.rs b/crates/ui-egui/src/tests_ruler_origin.rs index 2141f1414..62af6a478 100644 --- a/crates/ui-egui/src/tests_ruler_origin.rs +++ b/crates/ui-egui/src/tests_ruler_origin.rs @@ -77,11 +77,11 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert_eq!(&*app.session.doc().unwrap().doc, &*before, "preview does not edit the document"); frame(&mut app, &ctx, vec![press(to, false)], canvas::show); let st = app.session.doc().unwrap(); - assert!(st.doc.ruler_origin.distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin); + assert!(st.doc.ruler_origin_for(0).distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin_for(0)); assert_eq!(st.history.undo.len(), undo + 1); assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag"); assert!(st.doc.guides.is_empty()); - let origin = st.doc.ruler_origin; + let origin = st.doc.ruler_origin_for(0); app.run("edit.undo", json!({})).unwrap(); assert_eq!(&*app.session.doc().unwrap().doc, &*before); app.run("edit.redo", json!({})).unwrap(); @@ -91,6 +91,7 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() #[test] fn ruler_origin_double_click_resets_to_the_active_artboard() { let mut app = app(); + app.run("view.globalRulers", json!({"global": true})).unwrap(); app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap(); app.run("document.setRulerOrigin", json!({"x": 125, "y": 90})).unwrap(); let ctx = context(); @@ -201,3 +202,85 @@ fn ruler_origin_artboard_fields_preserve_the_absolute_reference_point() { } } } + +// View › Rulers › Change to Global / Artboard Rulers (Adobe's public help: +// https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html): +// artboard rulers, a new document's, count from the active artboard's top-left corner; global +// rulers from one zero point for the whole document. + +/// A document with a second artboard at (700, -250) and a rectangle on it at (720, -230). +fn two_boards() -> (VectorcraftApp, NodeId) { + let mut app = app(); + app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap(); + let id = NodeId(app.run("shape.rectangle", json!({"x": 720, "y": -230, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap()); + (app, id) +} + +#[test] +fn artboard_rulers_position_fields_count_from_the_active_artboard() { + for draw in [panels::properties::show as fn(&mut VectorcraftApp, &mut Ui), panels::transform::show, chrome::control_bar] { + let (mut app, id) = two_boards(); + let ctx = context(); + frame(&mut app, &ctx, vec![], draw); + app.view_mut().unwrap().artboard = 1; + assert_eq!(app.ruler_origin(), Point::new(700.0, -250.0)); + let texts = frame(&mut app, &ctx, vec![], draw); + // The reference point is the centre (760, -210): 60 and 40 from artboard 2's corner. + for v in [60.0, 40.0] { + assert!(texts.iter().any(|(t, _)| t == &Unit::Points.format(v)), "X and Y count from artboard 2: {texts:?}"); + } + // Typing a position puts the object there on the active artboard's rulers. + let label = texts.iter().find(|(t, _)| t == "X:").unwrap().1; + let at = pos2(label.right() + 40.0, label.center().y); + frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(at, true)], draw); + frame(&mut app, &ctx, vec![press(at, false)], draw); + frame(&mut app, &ctx, vec![key(Key::ArrowUp)], draw); + let bounds = app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert_eq!((bounds.x0, bounds.y0), (721.0, -230.0)); + // Global rulers: the same object reads from the document's zero point. + app.run("view.globalRulers", json!({"global": true})).unwrap(); + assert_eq!(app.ruler_origin(), Point::ZERO); + frame(&mut app, &ctx, vec![], draw); + let texts = frame(&mut app, &ctx, vec![], draw); + assert!(texts.iter().any(|(t, _)| t == &Unit::Points.format(761.0)), "{texts:?}"); + assert!(!texts.iter().any(|(t, _)| t == &Unit::Points.format(61.0)), "{texts:?}"); + } +} + +#[test] +fn ruler_origin_drag_with_artboard_rulers_sets_the_active_artboards_origin() { + let (mut app, _) = two_boards(); + app.select_tool("rectangle"); + let ctx = context(); + frame(&mut app, &ctx, vec![], canvas::show); + app.view_mut().unwrap().artboard = 1; + let v = app.view_mut().unwrap(); + v.center = Point::new(800.0, -175.0); + frame(&mut app, &ctx, vec![], canvas::show); + let goal = Point::new(760.0, -200.0); + let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(goal); + start_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, vec![press(to, false)], canvas::show); + let doc = app.session.doc().unwrap().doc.clone(); + assert!(doc.ruler_origin_for(1).distance(goal) < 1e-4, "{:?}", doc.ruler_origin_for(1)); + assert_eq!(doc.ruler_origin_for(0), Point::ZERO, "the first artboard keeps its own"); + assert_eq!(doc.ruler_origin, Point::ZERO, "the global zero point is apart"); + assert!(app.ruler_origin().distance(goal) < 1e-4); + app.view_mut().unwrap().artboard = 0; + assert_eq!(app.ruler_origin(), Point::ZERO); +} + +#[test] +fn change_to_global_rulers_menu_item_toggles_and_names_the_other_mode() { + let (mut app, _) = two_boards(); + let item = crate::menus::menu_tree().iter().find(|(title, _)| *title == "View").map(|(_, items)| format!("{items:?}")).unwrap(); + assert!(item.contains("view.globalRulers"), "View › Rulers lists it: {item}"); + assert_eq!(crate::menus::dynamic_label(&app, "view.globalRulers", "Change to Global Rulers"), "Change to Global Rulers"); + app.run("view.globalRulers", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.rulers, vectorcraft_doc::RulerMode::Global); + assert_eq!(crate::menus::dynamic_label(&app, "view.globalRulers", "Change to Global Rulers"), "Change to Artboard Rulers"); + app.run("view.globalRulers", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.rulers, vectorcraft_doc::RulerMode::Artboard); + let spec = vectorcraft_engine::command_specs().iter().find(|c| c.id == "view.globalRulers").unwrap(); + assert_eq!(spec.shortcut, Some("Cmd+Alt+R")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 16a879e57..71f74a058 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -267,10 +267,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G7 Views and windows - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. +- **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Missing (noted for reporters, 2026-10-14):** custom toolbars (Window › Toolbars › New Toolbar, #1759); panel widths kept with a workspace (#1753). Inside the estimate below. - **Missing:** multiple windows and Window › Arrange (Cascade, Tile, Float in Window, Consolidate All Windows), - panels on another monitor, New View/Edit Views, global/artboard ruler mode switch and video rulers. + panels on another monitor, New View/Edit Views, video rulers. - **Estimate:** 15–25 h. ## G6 Automation and scripting diff --git a/docs/mcp.md b/docs/mcp.md index 8f1775ec5..37d81bfb0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -958,6 +958,17 @@ mask is edited; called again it shows the artwork, still editing. It is view sta like the mask view: the grid shows behind the artboards of the documents that turn it on, on the canvas and in `ui.screenshot`, never in exports. It returns `{on}`, isn't undoable and doesn't mark the document changed. +`view.globalRulers {global?}` (View → Rulers → Change to Global / Artboard Rulers, Cmd+Alt+R; default: toggle) is +saved with the document and undoable. With artboard rulers (a new document's) the rulers count from the active +artboard's top-left corner, or the zero point set for it; with global rulers, from one document zero point. Command +coordinates stay absolute either way. `document.rulerOrigin {artboard?}` returns the zero point the rulers use with that +artboard active; `document.setRulerOrigin {x, y, artboard?}` and `document.resetRulerOrigin {artboard?}` set or reset +the document's (global rulers) or that artboard's (artboard rulers, kept relative to it so it follows the artboard). + +```json +{"name":"run_command","arguments":{"command":"view.globalRulers","params":{"global":true}}} +``` + ## Clipping masks `object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 06184d9c5..7a36748f5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -84,6 +84,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` | +| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | @@ -408,7 +409,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point finite-arithmetic follow-up (G1/M3):** Adjoining adjustments avoid intermediate overflow; non-finite normalized or resulting factors are rejected before committing. Engine regressions in `tests_widthpoints.rs` cover finite extremes, small nonzero results, atomic rejection, Undo/Redo and native save/reopen. Historical focused validation on the `a3357121` integration observed 2 expected RED failures with 11 passing controls, then all 13 tests passing with the repair; none were ignored. All three added regressions passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. Scores and hours unchanged. - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). -- **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Global/artboard mode switching and video rulers remain separate. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). +- **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). +- **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/ruler_origin.rs`, `ui-egui/src/tests_ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). Video rulers remain. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c341408fb..4db4294c3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -115,7 +115,8 @@ tools, never checked side by side) isn't captured row by row. | A selected ruler guide's X or Y in the Transform panel; typing a value moves it | not scored | #1659 (part of #1597); several guides move together; not in Properties or the Control bar. Not established by the documentation we could find: the help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) don't describe it, and the one public statement is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. Kept as a convenience pending the owner's decision; no parity claim | | Alt swaps a ruler guide's orientation while dragging | missing | | | Alt swaps a new ruler guide's orientation while dragging | done | Both rulers; preview, snapping and final placement follow the current modifier; existing-guide Alt-copy is unchanged. | -| Global/artboard ruler mode switch and video rulers | missing | menu stubs; custom document origin is done (#1212) | +| Global/artboard ruler mode switch (View › Rulers › Change to Global / Artboard Rulers, Cmd+Alt+R) | done | `view.globalRulers`; a new document has artboard rulers, counting from the active artboard's top-left corner or the zero point set for it (kept relative to the artboard); global rulers keep one document zero point. Rulers, Transform, Properties, Control and Info X/Y, guide readouts and Shift ruler-tick snapping follow the mode; saved with the document, older files open with global rulers. Behaviour from [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html) and [Use rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) | +| Video rulers | missing | menu stub | ## Transform handles, bounding box, Free Transform