diff --git a/ASSETS.md b/ASSETS.md
index 69ffc8a0..350780d1 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -99,12 +99,16 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-cap-butt.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-cap-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-cap-square.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-center-hide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dist-bottom.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dist-hcenter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -182,6 +186,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-reverse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rotate-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rounded-rect.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-rule-evenodd.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-rule-nonzero.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-scallop.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-screen-mode.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
diff --git a/assets/icons/dc-center-hide.svg b/assets/icons/dc-center-hide.svg
new file mode 100644
index 00000000..708d9dd2
--- /dev/null
+++ b/assets/icons/dc-center-hide.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/dc-center-show.svg b/assets/icons/dc-center-show.svg
new file mode 100644
index 00000000..5a86f3f5
--- /dev/null
+++ b/assets/icons/dc-center-show.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/dc-dir-off.svg b/assets/icons/dc-dir-off.svg
new file mode 100644
index 00000000..cb9e1ae0
--- /dev/null
+++ b/assets/icons/dc-dir-off.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/dc-dir-on.svg b/assets/icons/dc-dir-on.svg
new file mode 100644
index 00000000..0767b496
--- /dev/null
+++ b/assets/icons/dc-dir-on.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/dc-rule-evenodd.svg b/assets/icons/dc-rule-evenodd.svg
new file mode 100644
index 00000000..61cc703d
--- /dev/null
+++ b/assets/icons/dc-rule-evenodd.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/dc-rule-nonzero.svg b/assets/icons/dc-rule-nonzero.svg
new file mode 100644
index 00000000..627b4ee0
--- /dev/null
+++ b/assets/icons/dc-rule-nonzero.svg
@@ -0,0 +1 @@
+
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 0c676e3b..cbb76c5e 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -43,6 +43,7 @@ pub use graph::{GraphKind, GraphSpec};
pub use hit::{Hit, HitKind};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use node::Knockout;
+pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use selection::{AnchorRef, Selection};
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index 38c0b05c..e536bbf3 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -282,6 +282,9 @@ pub struct Node {
/// that style's look: editing its appearance or transparency breaks the link.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphic_style: Option,
+ /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub attrs: Option>,
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
@@ -328,6 +331,7 @@ impl Node {
graph: None,
kind,
graphic_style: None,
+ attrs: None,
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
@@ -909,6 +913,77 @@ impl Node {
}
}
+/// Window → Attributes: an object's settings that don't change how it prints.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ObjectAttributes {
+ /// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub show_center: Option,
+ /// Image Map: the clickable area the URL covers in web output.
+ #[serde(default, skip_serializing_if = "crate::skip::is_default")]
+ pub image_map: ImageMap,
+ /// URL the object links to (SVG ``; empty: none).
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub url: String,
+ /// The note shown in the Attributes panel.
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub note: String,
+}
+
+/// The Attributes panel's Image Map shapes.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ImageMap {
+ #[default]
+ None,
+ Rectangle,
+ Polygon,
+}
+
+impl ImageMap {
+ pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon];
+
+ pub fn label(self) -> &'static str {
+ match self {
+ ImageMap::None => "None",
+ ImageMap::Rectangle => "Rectangle",
+ ImageMap::Polygon => "Polygon",
+ }
+ }
+
+ /// Parse a label or serialized name, ignoring case.
+ pub fn parse(s: &str) -> Option {
+ Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
+ }
+}
+
+impl Node {
+ /// The URL this object links to, if any.
+ pub fn url(&self) -> Option<&str> {
+ self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
+ }
+
+ /// Whether the canvas shows this object's centre point when it is selected: as set in the
+ /// Attributes panel, else [`Self::shows_center_by_default`].
+ pub fn shows_center(&self) -> bool {
+ self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
+ }
+
+ /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
+ pub fn shows_center_by_default(&self) -> bool {
+ matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. }))
+ }
+
+ /// Change this object's attributes with `f`; all-default attributes are dropped.
+ pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R {
+ let mut a = self.attrs.take().unwrap_or_default();
+ let r = f(&mut a);
+ self.attrs = (*a != ObjectAttributes::default()).then_some(a);
+ r
+ }
+}
+
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs
index 7f2508cc..d38094bd 100644
--- a/crates/effects/src/bake.rs
+++ b/crates/effects/src/bake.rs
@@ -147,6 +147,8 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea
trace: None,
wrap: None,
graph: None,
+ // The group keeps the URL (one link around the pieces).
+ attrs: None,
kind,
..n.clone()
}
diff --git a/crates/engine/src/cmd/attributes.rs b/crates/engine/src/cmd/attributes.rs
new file mode 100644
index 00000000..d466c305
--- /dev/null
+++ b/crates/engine/src/cmd/attributes.rs
@@ -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 {
+ 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 {
+ 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(mut it: impl Iterator- ) -> Option {
+ 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,
+ image_map: Option,
+ url: Option,
+ note: Option,
+}
+
+impl Edits {
+ fn parse(p: &Value) -> Result {
+ const C: &str = "attributes.set";
+ let text = |k: &str| -> Result