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:
bflatastic
2026-10-05 03:46:04 +07:00
co-authored by Claude Opus 5.5
parent 9fcefde3f9
commit 4591c85845
30 changed files with 1011 additions and 75 deletions
+185
View File
@@ -0,0 +1,185 @@
//! Window → Attributes: overprint, centre point display, image map, URL and note
//! (`attributes.set`), the fill rule (`path.setFillRule`) and, through `path.reverse`, the path
//! direction. The panel reads everything back with `attributes.info` ([`super::overprint`]).
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageMap, Node, NodeKind, ObjectAttributes};
use vectorcraft_geom::FillRule;
use super::overprint::{AttributesInfo, apply_overprint, edit_counted, overprint_jobs};
use super::path::paths_in;
use super::*;
/// How many URLs the Attributes panel's recent list keeps.
const RECENT_URLS: usize = 10;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"attributes.set",
"Attributes",
[],
None,
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
has_doc,
set
),
cmd!(
"path.setFillRule",
"Fill Rule",
[],
None,
"{rule: \"nonZero\"|\"evenOdd\", ids?} the fill rule of the paths and compound paths in ids or the selection (groups: those inside), as one undo step → {changed}",
has_doc,
set_fill_rule
),
]
}
/// A fill rule as the commands name it.
pub(super) fn rule_name(r: FillRule) -> &'static str {
match r {
FillRule::NonZero => "nonZero",
FillRule::EvenOdd => "evenOdd",
}
}
fn parse_rule(s: &str) -> Option<FillRule> {
match s.to_ascii_lowercase().replace(['-', ' ', '_'], "").as_str() {
"nonzero" | "nonzerowinding" => Some(FillRule::NonZero),
"evenodd" => Some(FillRule::EvenOdd),
_ => None,
}
}
/// The one value every item of `it` shares, if any.
fn same<T: PartialEq>(mut it: impl Iterator<Item = T>) -> Option<T> {
let first = it.next()?;
it.all(|v| v == first).then_some(first)
}
/// The paths and compound paths in `n`'s subtree that have a fill rule of their own with it (not a
/// compound path's members, which fill with its rule).
fn ruled<'a>(n: &'a Node, out: &mut Vec<(&'a Node, FillRule)>) {
match &n.kind {
NodeKind::Path { rule, guide: false, .. } | NodeKind::Compound { rule, .. } => out.push((n, *rule)),
_ => n.children().into_iter().flatten().for_each(|c| ruled(c, out)),
}
}
/// The Attributes panel's values of `info.ids` beyond overprinting.
pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) {
let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect();
let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default());
info.show_center = same(nodes.iter().map(|n| n.shows_center()));
info.image_map = same(attrs().map(|a| a.image_map));
info.url = same(attrs().map(|a| a.url));
info.note = same(attrs().map(|a| a.note));
let mut all = vec![];
nodes.iter().for_each(|n| ruled(n, &mut all));
info.fill_rule = same(all.into_iter().map(|(_, r)| r));
let paths = paths_in(d, &info.ids);
let subpaths = paths.iter().filter_map(|id| d.node(*id)?.path_data()).flat_map(|p| &p.subpaths);
info.reversed = same(subpaths.map(|sp| sp.signed_area() < 0.0));
}
/// The attribute edits `p` asks for, applied to `a`.
#[derive(Default)]
struct Edits {
show_center: Option<bool>,
image_map: Option<ImageMap>,
url: Option<String>,
note: Option<String>,
}
impl Edits {
fn parse(p: &Value) -> Result<Self> {
const C: &str = "attributes.set";
let text = |k: &str| -> Result<Option<String>> {
p.get(k).map(|v| v.as_str().map(|s| s.trim().to_string()).ok_or_else(|| bad(C, format!("`{k}` must be a string")))).transpose()
};
Ok(Self {
show_center: p.get("showCenter").map(|v| v.as_bool().ok_or_else(|| bad(C, "`showCenter` must be true or false"))).transpose()?,
image_map: text("imageMap")?
.map(|s| ImageMap::parse(&s).ok_or_else(|| bad(C, "`imageMap` must be none, rectangle or polygon")))
.transpose()?,
url: text("url")?,
note: text("note")?,
})
}
fn is_empty(&self) -> bool {
self.show_center.is_none() && self.image_map.is_none() && self.url.is_none() && self.note.is_none()
}
/// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default).
fn apply(&self, n: &Node) -> ObjectAttributes {
let mut a = n.attrs.as_deref().cloned().unwrap_or_default();
if let Some(v) = self.show_center {
a.show_center = (v != n.shows_center_by_default()).then_some(v);
}
if let Some(v) = self.image_map {
a.image_map = v;
}
if let Some(v) = &self.url {
a.url.clone_from(v);
}
if let Some(v) = &self.note {
a.note.clone_from(v);
}
a
}
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "attributes.set";
let jobs = overprint_jobs(s, p, C, ["overprintFill", "overprintStroke"])?;
let edits = Edits::parse(p)?;
if jobs.is_empty() && edits.is_empty() {
return Err(bad(C, "give overprintFill, overprintStroke, showCenter, imageMap, url or note"));
}
let ids = targets(s, p)?;
let changed = edit_counted(s, "Attributes", |d| {
let mut changed = BTreeSet::new();
apply_overprint(d, &jobs, C, &mut changed)?;
if !edits.is_empty() {
for id in &ids {
let Some(n) = d.node(*id) else { continue };
let new = edits.apply(n);
if new != n.attrs.as_deref().cloned().unwrap_or_default()
&& let Some(n) = d.node_mut(*id)
{
n.edit_attrs(|a| *a = new);
changed.insert(*id);
}
}
}
Ok(changed.len())
})?;
if let Some(url) = edits.url.filter(|u| !u.is_empty() && !ids.is_empty()) {
s.recent_urls.retain(|u| *u != url);
s.recent_urls.insert(0, url);
s.recent_urls.truncate(RECENT_URLS);
}
Ok(json!({ "changed": changed }))
}
fn set_fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.setFillRule";
let rule = str_param(p, "rule").and_then(parse_rule).ok_or_else(|| bad(C, "`rule` must be nonZero or evenOdd"))?;
let ids = targets(s, p)?;
let changed = edit_counted(s, "Fill Rule", |d| {
let mut all = vec![];
ids.iter().filter_map(|id| d.node(*id)).for_each(|n| ruled(n, &mut all));
let todo: BTreeSet<_> = all.into_iter().filter(|(_, r)| *r != rule).map(|(n, _)| n.id).collect();
for id in &todo {
if let Some(NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*r = rule;
}
}
Ok(todo.len())
})?;
Ok(json!({ "changed": changed }))
}
+2
View File
@@ -1,6 +1,7 @@
//! The command registry. Ids follow Illustrator's menu structure.
pub(crate) mod appearance;
mod attributes;
mod brushsym;
mod buildcmds;
pub mod clipboard;
@@ -204,6 +205,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(flatten::specs());
v.extend(stylelib::specs());
v.extend(expand::specs());
v.extend(attributes::specs());
v
})
}
+60 -25
View File
@@ -7,7 +7,8 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::overprint::OverprintBlack;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_doc::{Document, ImageMap, Node, NodeId, NodeKind};
use vectorcraft_geom::FillRule;
use super::appearance::{ItemTarget, item_target};
use super::edit::selected_roots;
@@ -29,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Attributes",
[],
None,
"{ids?, item?} → {ids, overprintFill, overprintStroke} the Attributes panel's values for `ids` or the selection, aimed as object.setOverprint aims; a value is null where they differ (or nothing has a fill or stroke)",
"{ids?, item?} → {ids, overprintFill, overprintStroke, showCenter, imageMap: \"none\"|\"rectangle\"|\"polygon\", url, note, fillRule: \"nonZero\"|\"evenOdd\" (of the paths and compound paths in them), reversed (their subpaths run counter-clockwise on screen: Reverse Path Direction On), recentUrls: [newest first]} the Attributes panel's values for `ids` or the selection, overprint aimed as object.setOverprint aims; a value is null where they differ (or nothing has a fill, stroke or path)",
has_doc,
|s, p| Ok(attributes(s, p)?.to_json())
),
@@ -45,6 +46,17 @@ pub struct AttributesInfo {
pub overprint_fill: Option<bool>,
/// Overprint Stroke; `None` where the targets differ or have no stroke.
pub overprint_stroke: Option<bool>,
/// The rest of the panel ([`super::attributes`]); `None` where the targets differ.
pub show_center: Option<bool>,
pub image_map: Option<ImageMap>,
pub url: Option<String>,
pub note: Option<String>,
/// Fill rule of the paths and compound paths in the targets.
pub fill_rule: Option<FillRule>,
/// Whether their subpaths run counter-clockwise on screen (Reverse Path Direction On).
pub reversed: Option<bool>,
/// [`Session::recent_urls`].
pub recent_urls: Vec<String>,
}
impl AttributesInfo {
@@ -53,6 +65,13 @@ impl AttributesInfo {
"ids": self.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
"overprintFill": self.overprint_fill,
"overprintStroke": self.overprint_stroke,
"showCenter": self.show_center,
"imageMap": self.image_map,
"url": self.url,
"note": self.note,
"fillRule": self.fill_rule.map(super::attributes::rule_name),
"reversed": self.reversed,
"recentUrls": self.recent_urls,
})
}
}
@@ -97,9 +116,9 @@ fn flags_mut(n: &mut Node, fill: bool, index: Option<usize>) -> Vec<&mut bool> {
v
}
/// The `fill` and `stroke` params given: (fill?, on).
fn kinds(p: &Value, cmd: &str) -> Result<Vec<(bool, bool)>> {
[(true, "fill"), (false, "stroke")]
/// The fill and stroke overprint params given under `keys` (fill's, stroke's): (fill?, on).
fn kinds(p: &Value, cmd: &str, keys: [&str; 2]) -> Result<Vec<(bool, bool)>> {
[(true, keys[0]), (false, keys[1])]
.into_iter()
.filter_map(|(fill, key)| {
p.get(key).map(|v| v.as_bool().map(|on| (fill, on)).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))))
@@ -121,7 +140,7 @@ fn aims(s: &Session, p: &Value, cmd: &str, kinds: impl IntoIterator<Item = bool>
/// Run `f` on a copy of the document and commit it as one undo step (`label`) only when `f`
/// reports changes; returns their count.
fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
let mut d = (*s.doc()?.doc).clone();
let n = f(&mut d)?;
if n > 0 {
@@ -133,27 +152,42 @@ fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> R
Ok(n)
}
fn set_overprint(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.setOverprint";
let kinds = kinds(p, C)?;
if kinds.is_empty() {
return Err(bad(C, "give `fill` and/or `stroke`"));
}
let jobs: Vec<_> = aims(s, p, C, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect();
let changed = edit_counted(s, "Overprint", |d| {
let mut changed = BTreeSet::new();
for ((fill, item, ids), on) in &jobs {
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
let index = item.resolve(&n.appearance, *fill, C)?;
for f in flags_mut(n, *fill, index) {
if *f != *on {
*f = *on;
changed.insert(*id);
}
/// One overprint edit: (fill?, item, objects), on.
pub(super) type OverprintJob = ((bool, ItemTarget, Vec<NodeId>), bool);
/// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as
/// `object.setOverprint` aims.
pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result<Vec<OverprintJob>> {
let kinds = kinds(p, cmd, keys)?;
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect())
}
/// Apply `jobs` to `d`, adding the objects changed to `changed`.
pub(super) fn apply_overprint(d: &mut Document, jobs: &[OverprintJob], cmd: &str, changed: &mut BTreeSet<NodeId>) -> Result<()> {
for ((fill, item, ids), on) in jobs {
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
let index = item.resolve(&n.appearance, *fill, cmd)?;
for f in flags_mut(n, *fill, index) {
if *f != *on {
*f = *on;
changed.insert(*id);
}
}
}
}
Ok(())
}
fn set_overprint(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.setOverprint";
let jobs = overprint_jobs(s, p, C, ["fill", "stroke"])?;
if jobs.is_empty() {
return Err(bad(C, "give `fill` and/or `stroke`"));
}
let changed = edit_counted(s, "Overprint", |d| {
let mut changed = BTreeSet::new();
apply_overprint(d, &jobs, C, &mut changed)?;
Ok(changed.len())
})?;
Ok(json!({ "changed": changed }))
@@ -162,7 +196,8 @@ fn set_overprint(s: &mut Session, p: &Value) -> Result<Value> {
fn attributes(s: &Session, p: &Value) -> Result<AttributesInfo> {
const C: &str = "attributes.info";
let d = &s.doc()?.doc;
let mut info = AttributesInfo { ids: targets(s, p)?, ..Default::default() };
let mut info = AttributesInfo { ids: targets(s, p)?, recent_urls: s.recent_urls.clone(), ..Default::default() };
super::attributes::fill_info(d, &mut info);
for (fill, item, ids) in aims(s, p, C, [true, false])? {
let mut all =
ids.iter().filter_map(|id| d.node(*id)).filter_map(|n| Some(flags(n, fill, item.resolve(&n.appearance, fill, C).ok()?))).flatten();
+46 -7
View File
@@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_anchors
),
cmd!("path.reverse", "Reverse Path Direction", ["Object", "Path"], None, "{}", has_selection, reverse),
cmd!(
"path.reverse",
"Reverse Path Direction",
["Object", "Path"],
None,
"{ids?, reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
has_doc,
reverse
),
cmd!(
"path.join",
"Join",
@@ -233,15 +241,46 @@ fn selected_paths(s: &Session) -> Result<Vec<NodeId>> {
Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect())
}
fn reverse(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
s.edit("Reverse Path Direction", |d, _| {
/// The paths in the subtrees of `ids` (in groups and compound paths too), each once.
pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = BTreeSet::new();
for n in ids.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
out.insert(c.id);
}
});
}
out.into_iter().collect()
}
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reverse";
let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?;
let ids = paths_in(&s.doc()?.doc, &targets(s, p)?);
if ids.is_empty() {
return Err(bad(C, "select paths"));
}
let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| {
let mut changed = 0;
for id in &ids {
path_mut(d, *id)?.reverse();
// The subpaths to flip: all, or those not yet running the wanted way.
let flip: Vec<bool> = d
.node(*id)
.and_then(|n| n.path_data())
.map_or(vec![], |pd| pd.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect());
if flip.contains(&true) {
for (sp, f) in path_mut(d, *id)?.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
}
}
changed += 1;
}
}
Ok(())
Ok(changed)
})?;
ok()
Ok(json!({ "changed": changed }))
}
fn join(s: &mut Session, _: &Value) -> Result<Value> {
+5
View File
@@ -507,6 +507,8 @@ pub struct Session {
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
/// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved.
pub style_libraries: cmd::stylelib::Libraries,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
}
impl Default for Session {
@@ -541,6 +543,7 @@ impl Session {
swatch_libraries: Default::default(),
freeform_point: None,
style_libraries: Default::default(),
recent_urls: vec![],
}
}
@@ -787,6 +790,8 @@ mod tests;
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_attributes;
#[cfg(test)]
mod tests_brushsym;
#[cfg(test)]
mod tests_build;
+192
View File
@@ -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&amp;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");
}