diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index acca3c877..cc4f1c72e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -123,6 +123,9 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Swatch, SwatchGroup}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +/// Coordinates beyond this many points exceed the supported document range. +pub const MAX_COORD: f64 = 4.0e6; + #[derive(Debug, thiserror::Error, PartialEq)] pub enum DocError { #[error("no such object {0}")] @@ -749,6 +752,13 @@ type Places = BTreeMap, Vec<(usize, usize)>>; pub const FORMAT_VERSION: u32 = 1; impl Document { + /// The ruler zero point, or zero on both axes when either stored coordinate is invalid. + /// Rulers, coordinate fields and guide snapping use the same supported range. + pub fn ruler_origin_or_zero(&self) -> Point { + let origin = self.ruler_origin; + if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } 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) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a70821353..cd5908927 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -546,7 +546,7 @@ impl DocState { } /// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. -pub const MAX_COORD: f64 = 4.0e6; +pub use vectorcraft_doc::MAX_COORD; /// The commands that act on the active artboard (#693), which the app passes as their `artboard`: /// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 8722b16a3..eee78dda7 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -112,7 +112,9 @@ impl GuideSnap { fn snap(&mut self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> f64 { self.snapped.clear(); if shift { - return cx.unit.snap_to_ruler_tick(v, cx.zoom); + let origin = cx.doc.ruler_origin_or_zero(); + let origin = if vertical { origin.x } else { origin.y }; + return origin + cx.unit.snap_to_ruler_tick(v - origin, cx.zoom); } if cx.snap_to_pixel { return v.round(); @@ -130,8 +132,9 @@ 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 axis = if vertical { "X" } else { "Y" }; - o.push(Overlay::Measure { p: pointer, text: format!("{axis}: {}", cx.len(at)) }); + let origin = cx.doc.ruler_origin_or_zero(); + 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)) }); } o } @@ -631,4 +634,205 @@ mod tests { g.pointer(&c, &ev(PointerKind::Drag, 50.0, -6.0, m)); assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, -6.0, m)), Some(vec![Action::Commit])); } + + // Tick positions are explicit fixtures in each unit, independent of the snap implementation. + fn origin_tick_cases() -> Vec<(vectorcraft_doc::Unit, f64, Point, Point, Point, f64)> { + use vectorcraft_doc::Unit; + let mm = |v| Unit::Millimeters.to_pt(v); + let inch = |v| Unit::Inches.to_pt(v); + vec![ + (Unit::Points, 1.0, Point::new(17.0, 23.0), Point::new(223.0, 237.0), Point::new(222.0, 238.0), 5.0), + (Unit::Points, 1.0, Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(223.0, 237.0), 5.0), + (Unit::Points, 5.0, Point::new(17.25, 23.75), Point::new(223.4, 237.2), Point::new(223.25, 236.75), 1.0), + (Unit::Millimeters, 1.0, Point::new(mm(1.0), mm(-1.0)), Point::new(mm(78.7), mm(83.6)), Point::new(mm(78.5), mm(84.0)), mm(2.5)), + ( + Unit::Inches, + 2.0, + Point::new(inch(0.01), inch(0.03)), + Point::new(inch(3.07), inch(3.27)), + Point::new(inch(3.06), inch(3.28)), + inch(0.05), + ), + ] + } + + fn guide_preview_position(actions: &[Action], vertical: bool, from: Option) -> f64 { + let Some(Action::Preview(command, p)) = actions.iter().find(|a| matches!(a, Action::Preview(_, _))) else { + panic!("missing guide preview: {actions:?}"); + }; + if let Some(from) = from { + assert_eq!(command, "guide.move"); + from + p[if vertical { "dx" } else { "dy" }].as_f64().unwrap() + } else { + assert_eq!(command, "guide.add"); + assert_eq!(p["vertical"], json!(vertical)); + p["pos"].as_f64().unwrap() + } + } + + #[test] + fn ruler_guide_shift_new_uses_document_origin() { + for (unit, zoom, origin, pointer, expected, tick) in origin_tick_cases() { + let (mut doc, _) = doc_with_rect(); + doc.ruler_origin = origin; + 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] { + let mut guide = NewGuide::new(&c, vertical, None); + let mods = Mods { shift: true, ..Default::default() }; + let actions = guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true); + assert_eq!(actions.first(), Some(&Action::Begin("New Guide".into()))); + let want = if vertical { expected.x } else { expected.y }; + let actual = guide_preview_position(&actions, vertical, None); + assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} actual={actual} want={want}"); + assert!(guide.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, mods), true).is_empty()); + // Release updates to its own tick rather than committing a stale preview. + let actions = guide.pointer(&c, &ev(PointerKind::Up, pointer.x + tick * 2.0, pointer.y + tick * 2.0, mods), true); + assert!((guide_preview_position(&actions, vertical, None) - (want + tick * 2.0)).abs() < 1e-8); + assert_eq!(actions.last(), Some(&Action::Commit)); + assert!(guide.overlays(&c).is_empty()); + } + } + } + + #[test] + fn ruler_guide_shift_move_uses_document_origin() { + for (unit, zoom, origin, pointer, expected, _) in origin_tick_cases() { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + let paint = paint(); + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for copy in [false, true] { + let mut guide = GuideEdit::default(); + let (start, to, want) = if vertical { + (Point::new(from, 50.0), Point::new(pointer.x, 50.0), expected.x) + } else { + (Point::new(50.0, from), Point::new(50.0, pointer.y), expected.y) + }; + assert!(guide.press(&c, &ev(PointerKind::Down, start.x, start.y, Mods::default())).unwrap().is_empty()); + let mods = Mods { shift: true, alt: copy, ..Default::default() }; + let actions = guide.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods)).unwrap(); + assert_eq!(actions.first(), Some(&Action::Begin(if copy { "Copy Guide" } else { "Move Guide" }.into()))); + let actual = guide_preview_position(&actions, vertical, Some(from)); + assert!((actual - want).abs() < 1e-8, "{unit:?} {origin:?} vertical={vertical} copy={copy} actual={actual} want={want}"); + assert!(matches!(actions.last(), Some(Action::Preview(_, p)) if p["copy"] == json!(copy))); + assert_eq!(guide.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)), Some(vec![Action::Commit])); + assert!(!guide.busy()); + } + } + } + } + + #[test] + fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { + // One bad axis invalidates the entire origin, matching the app's ruler display. + for origin in [ + Point::ZERO, + Point::new(f64::NAN, 23.0), + Point::new(17.0, f64::NAN), + Point::new(f64::INFINITY, 23.0), + Point::new(17.0, f64::NEG_INFINITY), + Point::new(4_000_001.0, 23.0), + Point::new(17.0, -4_000_001.0), + ] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + 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(); + selection.set_guides([index]); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + let mods = Mods { shift: true, ..Default::default() }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, 223.0, 237.0, mods), true); + assert_eq!(guide_preview_position(&actions, vertical, None), want); + let mut moved = GuideEdit::default(); + let (start, to) = if vertical { ((from, 50.0), (223.0, 50.0)) } else { ((50.0, from), (50.0, 237.0)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, mods)).unwrap(); + assert_eq!(guide_preview_position(&actions, vertical, Some(from)), want); + } + } + } + + #[test] + fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { + 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; + let paint = paint(); + for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + for (c, pointer, expected) in [ + (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), + (ToolContext { snap_to_pixel: true, ..off }, Point::new(226.4, 237.6), Point::new(226.0, 238.0)), + (ToolContext { snap_to_grid: true, ..off }, Point::new(226.4, 237.6), Point::new(225.0, 234.0)), + (ToolContext { smart_guides: true, ..off }, Point::new(203.0, 102.0), Point::new(200.0, 100.0)), + (ToolContext { snap_to_point: true, ..off }, Point::new(201.0, 102.0), Point::new(200.0, 100.0)), + ] { + let want = if vertical { expected.x } else { expected.y }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, pointer.x, pointer.y, Mods::default()), true); + assert!((guide_preview_position(&actions, vertical, None) - want).abs() < 1e-8); + let mut moved = GuideEdit::default(); + let (start, to) = + if vertical { ((from, pointer.y), (pointer.x, pointer.y)) } else { ((pointer.x, from), (pointer.x, pointer.y)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + let actions = moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods::default())).unwrap(); + assert!((guide_preview_position(&actions, vertical, Some(from)) - want).abs() < 1e-8); + } + } + } + } + + #[test] + fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { + for (origin, expected) in [ + (Point::new(17.0, 23.0), Point::new(205.0, 215.0)), + (Point::new(-17.0, -23.0), Point::new(239.0, 261.0)), + (Point::ZERO, Point::new(222.0, 238.0)), + (Point::new(f64::NAN, 23.0), Point::new(222.0, 238.0)), + (Point::new(17.0, 4_000_001.0), Point::new(222.0, 238.0)), + ] { + let mut doc = guides_doc(); + doc.ruler_origin = origin; + 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)] { + let mut selection = Selection::default(); + selection.set_guides([index]); + let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; + let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); + let check = |overlays: Vec| { + assert!( + matches!(overlays.last(), Some(Overlay::Measure { text, .. }) if text == &want), + "{origin:?} {unit:?}: {overlays:?}, want {want}" + ); + }; + let mut new = NewGuide::new(&c, vertical, None); + let actions = new.pointer(&c, &ev(PointerKind::Drag, 222.0, 238.0, Mods::default()), true); + assert_eq!( + guide_preview_position(&actions, vertical, None), + if vertical { 222.0 } else { 238.0 }, + "commands keep absolute coordinates" + ); + check(new.overlays(&c)); + for copy in [false, true] { + let mut moved = GuideEdit::default(); + let (start, to) = if vertical { ((from, 50.0), (222.0, 50.0)) } else { ((50.0, from), (50.0, 238.0)) }; + moved.press(&c, &ev(PointerKind::Down, start.0, start.1, Mods::default())).unwrap(); + moved.pointer(&c, &ev(PointerKind::Drag, to.0, to.1, Mods { alt: copy, ..Default::default() })).unwrap(); + check(moved.overlays(&c)); + } + let quiet = ToolContext { measurement_labels: false, ..c }; + assert!(new.overlays(&quiet).is_empty(), "measurement-label preference is preserved"); + } + } + } + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 94aec5c3a..d0bdea9ee 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5018,4 +5018,197 @@ mod tests { near(select(&mut app, &a), under_a); near(select(&mut app, &b), under_b); } + + fn ruler_guide_origin_ui(alt: bool, cancel: bool) { + use egui::{Event, Modifiers}; + for (origin, preview, release) in [ + (Point::new(17.0, 23.0), Point::new(222.0, 238.0), Point::new(232.0, 243.0)), + (Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(233.0, 242.0)), + (Point::ZERO, Point::new(225.0, 235.0), Point::new(230.0, 245.0)), + ] { + for source_vertical in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": origin.x, "y": origin.y})).unwrap(); + app.run("shape.rectangle", json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap(); + let redo = app.session.active().unwrap().doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + app.session.active_mut().unwrap().mark_saved(); + app.ui.view.rulers = true; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + app.ui.view.snap_to_grid = false; + app.ui.view.snap_to_pixel = false; + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 1.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let from = + if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) }; + let to = xf.to_screen(Point::new(223.0, 237.0)); + let end = xf.to_screen(Point::new(231.0, 244.0)); + assert!(rect.contains(to) && rect.contains(end)); + let mods = if alt { Modifiers::SHIFT | Modifiers::ALT } else { Modifiers::SHIFT }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: mods }; + let before = app.session.active().unwrap().doc.clone(); + let selection = app.session.active().unwrap().selection.clone(); + let history = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) + }; + let original_history = history(&app); + assert_eq!(original_history.1, 1); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods), Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![button(from, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + let check = |app: &VectorcraftApp, switched: bool, expected: Point| { + let guides = &app.session.active().unwrap().doc.guides; + assert_eq!(guides.len(), 1); + let vertical = source_vertical != switched; + assert_eq!(guides[0].vertical, vertical); + let want = if vertical { expected.x } else { expected.y }; + assert!((guides[0].pos - want).abs() < 1e-4, "origin={origin:?} vertical={vertical}: {:?}, want {want}", guides[0]); + }; + assert_eq!(history(&app).0, original_history.0, "preview has no undo entry"); + if cancel { + // Cancellation is a baseline positive control, independent of snap correctness. + assert_eq!(app.session.active().unwrap().doc.guides.len(), 1); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(app.session.active().unwrap().selection, selection); + assert_eq!(history(&app), original_history); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, redo); + continue; + } + check(&app, alt, preview); + if alt { + // Used only by the private #1782 compatibility regression, not standalone RED. + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + check(&app, false, preview); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods)]); + check(&app, true, preview); + } + 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.layers, before.layers, "the rectangle tool draws no artwork"); + assert_eq!(st.selection, selection); + assert_eq!(history(&app), (original_history.0 + 1, 0, true)); + assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); + let after = st.doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(history(&app), (original_history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, after); + } + } + } + + #[test] + fn ruler_guide_shift_origin_preview_release_and_history() { + ruler_guide_origin_ui(false, false); + } + + #[test] + fn ruler_guide_shift_origin_cancel_preserves_redo() { + ruler_guide_origin_ui(false, true); + } + + #[test] + 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(); + let max = vectorcraft_engine::MAX_COORD; + for (origin, expected) in [ + (Point::ZERO, Point::ZERO), + (Point::new(17.0, -23.0), Point::new(17.0, -23.0)), + (Point::new(max, -max), Point::new(max, -max)), + (Point::new(f64::NAN, 23.0), Point::ZERO), + (Point::new(17.0, f64::INFINITY), Point::ZERO), + (Point::new(max + 1.0, 23.0), Point::ZERO), + (Point::new(17.0, -max - 1.0), Point::ZERO), + ] { + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).ruler_origin = origin; + assert_eq!(app.ruler_origin(), expected); + } + } + + #[test] + fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { + use egui::Event; + for (vertical, target, shown) in [(true, 222.0, 205.0), (false, 238.0, 215.0)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": 17, "y": 23})).unwrap(); + app.run("guide.add", json!({"vertical": vertical, "pos": 100})).unwrap(); + app.ui.view.guides = true; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + app.ui.view.snap_to_grid = false; + app.ui.view.snap_to_pixel = false; + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 1.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + app.session.active_mut().unwrap().mark_saved(); + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let from = xf.to_screen(if vertical { Point::new(100.0, 50.0) } else { Point::new(50.0, 100.0) }); + let to = xf.to_screen(if vertical { Point::new(target, 50.0) } else { Point::new(50.0, target) }); + assert!(rect.contains(from) && rect.contains(to)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); + frame(&mut app, &ctx, vec![button(from, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4); + assert_eq!(app.session.active().unwrap().selection.guides, vec![0]); + // Observe the real selected-guide field; no expected strings are inserted into UI state. + let fields = egui::Context::default(); + crate::theme::install_fonts(&fields); + let mut texts = vec![]; + for _ in 0..3 { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() }; + let mut out = fields.run_ui(input, |ui| crate::panels::transform::show(&mut app, ui)); + out.textures_delta.clear(); + fn collect(shape: &egui::Shape, texts: &mut Vec) { + match shape { + egui::Shape::Text(t) => texts.push(t.galley.text().to_string()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, texts)), + _ => {} + } + } + texts.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut texts)); + } + assert!(texts.iter().any(|t| t == &Unit::Points.format(shown)), "{texts:?}"); + let view = app.view_info(); + let overlays = app.session.overlays(view); + let want = format!("{}: {shown:.2} pt", if vertical { "X" } else { "Y" }); + assert!( + overlays.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Measure { text, .. } if text == &want)), + "{overlays:?}, field={shown}" + ); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + frame(&mut app, &ctx, vec![button(to, false)]); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e8cd0671e..95af996a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -618,13 +618,7 @@ impl VectorcraftApp { /// The zero point shown by rulers and position fields; command coordinates stay absolute. pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point { - let origin = self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin); - // An untrusted file cannot make the ruler's tick loop or position fields overflow. - if origin.is_finite() && origin.x.abs() <= vectorcraft_engine::MAX_COORD && origin.y.abs() <= vectorcraft_engine::MAX_COORD { - origin - } else { - vectorcraft_geom::Point::ZERO - } + self.session.active().map_or(vectorcraft_geom::Point::ZERO, |d| d.doc.ruler_origin_or_zero()) } pub fn view_mut(&mut self) -> Option<&mut View> { self.sync_views();