mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
M3.76: Attributes panel with overprint, centre point, path direction, fill rule, image map, URL and note
Window > Attributes (Cmd+F11) shows Overprint Fill/Stroke, Show/Don't Show Center, Reverse Path Direction Off/On, the non-zero / even-odd fill rule, Image Map, a URL field with recent URLs and a Browser button, and a note. New commands attributes.set and path.setFillRule; path.reverse takes ids and reversed; attributes.info reports every value. Objects keep the attributes in Node.attrs. SVG export wraps linked objects in <a>, and import reads links back. The canvas draws the centre point of selected objects that show it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9fcefde3f9
commit
4591c85845
@@ -0,0 +1,192 @@
|
||||
//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`)
|
||||
//! and the links SVG export writes for objects with a URL.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ImageMap, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule};
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: Value) -> Value {
|
||||
run(s, "attributes.info", p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_attribute_sets_reads_back_and_undoes_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let i = info(&mut s, json!({}));
|
||||
assert_eq!((i["overprintFill"].clone(), i["showCenter"].clone(), i["imageMap"].clone()), (json!(false), json!(true), json!("none")));
|
||||
assert_eq!((i["url"].clone(), i["note"].clone(), i["fillRule"].clone()), (json!(""), json!(""), json!("nonZero")));
|
||||
let changed = run(
|
||||
&mut s,
|
||||
"attributes.set",
|
||||
json!({"overprintFill": true, "overprintStroke": true, "showCenter": false, "imageMap": "Rectangle", "url": " https://example.com ", "note": "hello"}),
|
||||
);
|
||||
assert_eq!(changed["changed"], 1);
|
||||
let i = info(&mut s, json!({}));
|
||||
for (k, v) in [
|
||||
("overprintFill", json!(true)),
|
||||
("overprintStroke", json!(true)),
|
||||
("showCenter", json!(false)),
|
||||
("imageMap", json!("rectangle")),
|
||||
("url", json!("https://example.com")),
|
||||
("note", json!("hello")),
|
||||
("recentUrls", json!(["https://example.com"])),
|
||||
] {
|
||||
assert_eq!(i[k], v, "{k}");
|
||||
}
|
||||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||||
let at = n.attrs.as_deref().unwrap();
|
||||
assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com")));
|
||||
assert!(!n.shows_center());
|
||||
// One undo step takes everything back, and the attributes go away entirely.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||||
assert!(n.attrs.is_none() && !n.has_overprint());
|
||||
// Nothing to change: no undo step; bad values are refused.
|
||||
let before = s.doc().unwrap().revision;
|
||||
assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0);
|
||||
assert_eq!(s.doc().unwrap().revision, before);
|
||||
assert!(s.execute("attributes.set", &json!({"imageMap": "circle"})).is_err());
|
||||
assert!(s.execute("attributes.set", &json!({})).is_err());
|
||||
// Clearing the URL and note drops the attributes.
|
||||
run(&mut s, "attributes.set", json!({"url": "u", "note": "n"}));
|
||||
run(&mut s, "attributes.set", json!({"url": "", "note": ""}));
|
||||
assert!(s.doc().unwrap().doc.node(a).unwrap().attrs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_that_differ_read_null_and_recent_urls_keep_the_newest_first() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0);
|
||||
let b = rect(&mut s, 50.0);
|
||||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example", "note": "a"}));
|
||||
run(&mut s, "attributes.set", json!({"ids": [b.0], "url": "https://b.example"}));
|
||||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example"}));
|
||||
let i = info(&mut s, json!({"ids": [a.0, b.0]}));
|
||||
assert_eq!((i["url"].clone(), i["note"].clone(), i["imageMap"].clone()), (Value::Null, Value::Null, json!("none")));
|
||||
assert_eq!(i["recentUrls"], json!(["https://a.example", "https://b.example"]));
|
||||
// A pen path doesn't show its centre by default; a rectangle does.
|
||||
let p = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
||||
assert_eq!(info(&mut s, json!({"ids": [p.0]}))["showCenter"], false);
|
||||
assert_eq!(info(&mut s, json!({"ids": [p.0, a.0]}))["showCenter"], Value::Null);
|
||||
}
|
||||
|
||||
/// A five-pointed star drawn as one self-intersecting path (its centre is wound twice).
|
||||
fn star(s: &mut Session) -> NodeId {
|
||||
let (c, r) = (50.0f64, 45.0f64);
|
||||
let anchors: Vec<Value> = (0..5)
|
||||
.map(|i| {
|
||||
let a = (-90.0 + 144.0 * i as f64).to_radians();
|
||||
json!({"x": c + r * a.cos(), "y": c + r * a.sin()})
|
||||
})
|
||||
.collect();
|
||||
let id = NodeId(run(s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"ids": [id.0], "color": "#ff0000"}));
|
||||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||||
id
|
||||
}
|
||||
|
||||
fn red_at_centre(s: &Session) -> bool {
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let p = Renderer::new().render(&doc, 100, 100, Affine::IDENTITY, &opts).pixel(50, 52);
|
||||
p[0] > 200 && p[1] < 80
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_even_odd_star_has_a_hollow_centre() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
assert!(red_at_centre(&s), "non-zero fills the centre");
|
||||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 1);
|
||||
assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
||||
assert_eq!(info(&mut s, json!({}))["fillRule"], "evenOdd");
|
||||
assert!(!red_at_centre(&s), "even-odd leaves it empty");
|
||||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(red_at_centre(&s));
|
||||
assert!(s.execute("path.setFillRule", &json!({"rule": "odd"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_rule_reaches_compound_paths_and_the_paths_in_groups() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0);
|
||||
let b = rect(&mut s, 50.0);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "object.compoundPath.make", json!({}));
|
||||
let compound = s.doc().unwrap().selection.objects[0];
|
||||
let d = rect(&mut s, 0.0);
|
||||
run(&mut s, "select.set", json!({"ids": [compound.0, d.0]}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 2, "the compound path and the rectangle");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.node(compound).unwrap().kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
|
||||
assert!(matches!(doc.node(d).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_path_direction_on_and_off_set_the_winding() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let ccw = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().subpaths[0].signed_area() < 0.0;
|
||||
let start = ccw(&s);
|
||||
assert_eq!(info(&mut s, json!({}))["reversed"], start);
|
||||
// Off / On set it; asking again changes nothing.
|
||||
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 1);
|
||||
assert_eq!(ccw(&s), !start);
|
||||
assert_eq!(info(&mut s, json!({}))["reversed"], !start);
|
||||
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 0);
|
||||
// Without `reversed` it flips.
|
||||
run(&mut s, "path.reverse", json!({}));
|
||||
assert_eq!(ccw(&s), start);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributes_survive_a_native_round_trip() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"}));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.node(a).unwrap().attrs, doc.node(a).unwrap().attrs);
|
||||
assert!(back.node(a).unwrap().attrs.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_export_links_objects_and_import_reads_the_links_back() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
rect(&mut s, 50.0);
|
||||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://example.com/?a=1&b=2"}));
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert_eq!(svg.matches("<a ").count(), 1, "{svg}");
|
||||
assert!(svg.contains("xlink:href=\"https://example.com/?a=1&b=2\""), "{svg}");
|
||||
let back = vectorcraft_svg::import(&svg).unwrap();
|
||||
let mut urls = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Path { .. } = n.kind {
|
||||
urls.push(n.url().map(str::to_string));
|
||||
}
|
||||
});
|
||||
assert_eq!(urls, [Some("https://example.com/?a=1&b=2".to_string()), None], "the link wraps the path alone");
|
||||
}
|
||||
Reference in New Issue
Block a user