feat: change and reset the ruler origin (#1212)

This commit is contained in:
Ikki Nishihara
2026-10-12 15:17:24 +07:00
committed by bflatastic
parent 932c127323
commit 29ecab8c48
25 changed files with 531 additions and 40 deletions
+49 -1
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_geom::{Affine, Vec2};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
use super::*;
@@ -37,6 +37,25 @@ pub fn specs() -> Vec<CommandSpec> {
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!(
"document.setRulerOrigin",
"Ruler Origin",
[],
None,
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
has_doc,
set_ruler_origin
),
cmd!(
"document.resetRulerOrigin",
"Reset Ruler Origin",
[],
None,
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
has_doc,
reset_ruler_origin
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
@@ -180,6 +199,35 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
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")
}
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
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")
}
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.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 {
s.edit(label, |d, _| {
d.ruler_origin = origin;
Ok(())
})?;
}
Ok(json!(origin))
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).
pub(crate) fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
+9 -4
View File
@@ -556,10 +556,15 @@ fn strip_replay_only(id: &str, p: &mut Value) {
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
fn doc_sane(d: &Document, sel: &Selection) -> bool {
let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
d.artboards.iter().all(|a| ok(a.rect)) && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && {
let paths = d.index_paths(sel.objects.iter().copied());
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
}
d.ruler_origin.is_finite()
&& d.ruler_origin.x.abs() <= MAX_COORD
&& d.ruler_origin.y.abs() <= MAX_COORD
&& d.artboards.iter().all(|a| ok(a.rect))
&& d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD)
&& {
let paths = d.index_paths(sel.objects.iter().copied());
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
}
}
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).
+99
View File
@@ -0,0 +1,99 @@
//! #1212: ruler coordinates are saved metadata, independent of the artwork's coordinates.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_format::{SaveOptions, load, save, save_with};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 125})).unwrap();
s
}
fn origin(s: &mut Session) -> Value {
s.execute("document.rulerOrigin", &json!({})).unwrap()
}
#[test]
fn changing_origin_preserves_artwork_and_selection_and_is_one_undo_step() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(origin(&mut s), json!({"x": 0.0, "y": 0.0}));
let p = json!({"x": 125.5, "y": -30.25});
s.execute("document.setRulerOrigin", &p).unwrap();
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");
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;
s.execute("document.setRulerOrigin", &p).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "same origin is a no-op");
assert_eq!(s.doc().unwrap().revision, revision);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(&*s.doc().unwrap().doc, &*before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(origin(&mut s), p);
}
#[test]
fn reset_uses_the_requested_artboards_top_left_and_ignores_noops() {
let mut s = session();
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();
s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap();
assert_eq!(origin(&mut s), json!({"x": 700.0, "y": -250.0}));
assert_eq!(s.doc().unwrap().doc.artboards, boards);
let undo = s.doc().unwrap().history.undo.len();
s.execute("document.resetRulerOrigin", &json!({"artboard": 1})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
for p in [json!({"artboard": 9}), json!({"artboard": -1}), json!({"artboard": "first"})] {
assert!(s.execute("document.resetRulerOrigin", &p).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
s.execute("document.resetRulerOrigin", &json!({})).unwrap();
assert_eq!(origin(&mut s), json!({"x": 0.0, "y": 0.0}));
}
#[test]
fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero() {
let mut s = session();
let legacy = String::from_utf8(save(&s.doc().unwrap().doc, false)).unwrap().replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
assert!(serde_json::to_value(load(legacy.as_bytes()).unwrap()).unwrap().get("ruler_origin").is_none());
let p = json!({"x": -12.5, "y": 27.75});
s.execute("document.setRulerOrigin", &p).unwrap();
let doc = &s.doc().unwrap().doc;
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.layers, doc.layers);
assert_eq!(back.guides, doc.guides);
}
}
#[test]
fn origin_rejects_invalid_coordinates_without_changing_absolute_command_coordinates() {
let mut s = session();
s.execute("document.setRulerOrigin", &json!({"x": 200, "y": 150})).unwrap();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for p in
[json!({"x": 4_000_001, "y": 0}), json!({"x": 0, "y": -4_000_001}), json!({"x": "10", "y": 0}), json!({"x": 0}), json!({"x": null, "y": 0})]
{
assert!(s.execute("document.setRulerOrigin", &p).is_err(), "{p}");
assert_eq!(&*s.doc().unwrap().doc, &*before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
s.execute("object.setBounds", &json!({"x": 80, "y": 40, "reference": 0})).unwrap();
let bounds = s.doc().unwrap().doc.bounds_of(&s.doc().unwrap().selection.objects, false).unwrap();
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}));
}