mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
As the branch does for older tests: `panic!("…")` instead of
`unreachable!()` in test code (clippy.toml only allows unwrap, expect and
panic there), and the integration tests main added (CLI file I/O, render
gradient text and stroke geometry, SVG export suites) opt out of the
unwrap/expect/panic lints. No test logic changed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1166 lines
49 KiB
Rust
1166 lines
49 KiB
Rust
//! Document nodes.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use vectorcraft_color::BlendMode;
|
||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||
|
||
use crate::appearance::Appearance;
|
||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||
use crate::pattern::RepeatSpec;
|
||
use crate::text::TextObject;
|
||
|
||
/// Stable per-document object id. Never reused.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||
#[serde(transparent)]
|
||
pub struct NodeId(pub u64);
|
||
|
||
impl std::fmt::Display for NodeId {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "#{}", self.0)
|
||
}
|
||
}
|
||
|
||
/// Layer colours used for selection highlighting (index into [`LAYER_COLORS`]) or a custom RGB.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum LayerColor {
|
||
Preset(u8),
|
||
Custom([u8; 3]),
|
||
}
|
||
|
||
/// Layer highlight colours, assigned in sequence to new layers. First is Illustrator-like light blue.
|
||
pub const LAYER_COLORS: [(&str, [u8; 3]); 27] = [
|
||
("Light Blue", [0x4f, 0x80, 0xff]),
|
||
("Red", [0xff, 0x4f, 0x4f]),
|
||
("Green", [0x4f, 0xff, 0x4f]),
|
||
("Blue", [0x4f, 0x4f, 0xff]),
|
||
("Yellow", [0xff, 0xff, 0x4f]),
|
||
("Magenta", [0xff, 0x4f, 0xff]),
|
||
("Cyan", [0x4f, 0xff, 0xff]),
|
||
("Gray", [0x80, 0x80, 0x80]),
|
||
("Black", [0x00, 0x00, 0x00]),
|
||
("Orange", [0xff, 0x66, 0x00]),
|
||
("Dark Green", [0x00, 0x80, 0x00]),
|
||
("Teal", [0x00, 0x80, 0x80]),
|
||
("Tan", [0xcc, 0x99, 0x66]),
|
||
("Brown", [0x99, 0x33, 0x00]),
|
||
("Violet", [0x99, 0x33, 0xff]),
|
||
("Gold", [0xff, 0x99, 0x00]),
|
||
("Dark Blue", [0x00, 0x00, 0x80]),
|
||
("Pink", [0xff, 0x99, 0xcc]),
|
||
("Lavender", [0x99, 0x99, 0xff]),
|
||
("Brick Red", [0x99, 0x00, 0x00]),
|
||
("Olive Green", [0x66, 0x66, 0x00]),
|
||
("Peach", [0xff, 0x99, 0x99]),
|
||
("Burgundy", [0x99, 0x00, 0x33]),
|
||
("Grass Green", [0x99, 0xcc, 0x00]),
|
||
("Ochre", [0x99, 0x66, 0x00]),
|
||
("Purple", [0x66, 0x00, 0x99]),
|
||
("Light Gray", [0xbb, 0xbb, 0xbb]),
|
||
];
|
||
|
||
impl LayerColor {
|
||
pub fn rgb(self) -> [u8; 3] {
|
||
match self {
|
||
LayerColor::Preset(i) => LAYER_COLORS[i as usize % LAYER_COLORS.len()].1,
|
||
LayerColor::Custom(c) => c,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these
|
||
/// while the shape stays live; editing anchors directly converts it to a plain path.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "shape", rename_all = "lowercase")]
|
||
pub enum LiveShape {
|
||
Rectangle {
|
||
/// Untransformed width/height.
|
||
w: f64,
|
||
h: f64,
|
||
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
|
||
radii: [f64; 4],
|
||
/// Maps the untransformed shape (origin at its top-left) into the document.
|
||
xf: Affine,
|
||
},
|
||
Ellipse {
|
||
w: f64,
|
||
h: f64,
|
||
/// Pie start/end angles in degrees (0/360 = full ellipse).
|
||
pie: (f64, f64),
|
||
xf: Affine,
|
||
},
|
||
Polygon {
|
||
radius: f64,
|
||
sides: u32,
|
||
xf: Affine,
|
||
},
|
||
Line {
|
||
a: Point,
|
||
b: Point,
|
||
},
|
||
}
|
||
|
||
impl LiveShape {
|
||
pub fn label(&self) -> &'static str {
|
||
match self {
|
||
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle",
|
||
LiveShape::Rectangle { .. } => "Rectangle",
|
||
LiveShape::Ellipse { .. } => "Ellipse",
|
||
LiveShape::Polygon { .. } => "Polygon",
|
||
LiveShape::Line { .. } => "Line",
|
||
}
|
||
}
|
||
/// Regenerate the path.
|
||
pub fn to_path(&self) -> PathData {
|
||
match self {
|
||
LiveShape::Rectangle { w, h, radii, xf } => {
|
||
let r = Rect::new(0.0, 0.0, *w, *h);
|
||
let p = if radii.iter().all(|x| (x - radii[0]).abs() < 1e-9) {
|
||
shapes::rounded_rectangle(r, radii[0])
|
||
} else {
|
||
// Per-corner radii: generate uniformly then adjust (approximation until M2.5).
|
||
shapes::rounded_rectangle(r, radii.iter().cloned().fold(0.0, f64::max))
|
||
};
|
||
p.transformed(*xf)
|
||
}
|
||
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
|
||
LiveShape::Polygon { radius, sides, xf } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
|
||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||
}
|
||
}
|
||
pub fn transform(&mut self, a: Affine) {
|
||
match self {
|
||
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
|
||
LiveShape::Line { a: p, b } => {
|
||
*p = a * *p;
|
||
*b = a * *b;
|
||
}
|
||
}
|
||
}
|
||
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
|
||
/// keep their size (Scale Corners off). False when nothing changed.
|
||
pub fn keep_corners(&mut self, k: f64) -> bool {
|
||
match self {
|
||
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) && k > 1e-12 => {
|
||
radii.iter_mut().for_each(|r| *r /= k);
|
||
true
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
|
||
pub fn angle_deg(&self) -> f64 {
|
||
match self {
|
||
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||
let c = xf.as_coeffs();
|
||
c[1].atan2(c[0]).to_degrees()
|
||
}
|
||
LiveShape::Line { a, b } => (b.y - a.y).atan2(b.x - a.x).to_degrees(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What a transform scales besides geometry, by its mean scale (the square root of its
|
||
/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke
|
||
/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects
|
||
/// and Scale Corners.
|
||
#[derive(Clone, Copy, Debug, Default)]
|
||
pub struct Scaling {
|
||
/// Stroke weights, dash lengths and dash offsets scale.
|
||
pub strokes: bool,
|
||
/// With `strokes`: scales an effect's distance parameters (the effects catalogue's).
|
||
pub effects: Option<fn(&mut crate::Effect, f64)>,
|
||
/// Type keeps its character strokes' weight (they otherwise scale with the type).
|
||
pub keep_type_strokes: bool,
|
||
/// Live corner radii keep their size.
|
||
pub keep_corners: bool,
|
||
}
|
||
|
||
impl Scaling {
|
||
/// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by.
|
||
pub fn factor(a: Affine) -> Option<f64> {
|
||
let k = a.determinant().abs().sqrt();
|
||
((k - 1.0).abs() > 1e-9).then_some(k)
|
||
}
|
||
}
|
||
|
||
impl From<bool> for Scaling {
|
||
fn from(strokes: bool) -> Self {
|
||
Self { strokes, ..Self::default() }
|
||
}
|
||
}
|
||
|
||
/// Embedded or linked raster image.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct ImageObject {
|
||
/// Key into [`crate::Document::images`].
|
||
pub key: String,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Maps pixel space (0..w, 0..h) into the document.
|
||
pub xf: Affine,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub link: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "type", rename_all = "lowercase")]
|
||
pub enum NodeKind {
|
||
/// A top-level layer or sublayer.
|
||
Layer {
|
||
color: LayerColor,
|
||
#[serde(default)]
|
||
template: bool,
|
||
#[serde(default = "yes")]
|
||
printable: bool,
|
||
children: Vec<Arc<Node>>,
|
||
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
clip: bool,
|
||
},
|
||
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
|
||
Group {
|
||
children: Vec<Arc<Node>>,
|
||
#[serde(default)]
|
||
clip: bool,
|
||
},
|
||
Path {
|
||
path: PathData,
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
rule: FillRule,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
live: Option<LiveShape>,
|
||
/// This path acts as a clipping path inside a clip group.
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
clipping: bool,
|
||
/// Guide (non-printing) path.
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
guide: bool,
|
||
},
|
||
/// Compound path: children are paths painted as one with the compound's appearance.
|
||
Compound {
|
||
children: Vec<Arc<Node>>,
|
||
#[serde(default)]
|
||
rule: FillRule,
|
||
},
|
||
Text(Box<TextObject>),
|
||
Image(ImageObject),
|
||
SymbolInstance {
|
||
symbol: String,
|
||
xf: Affine,
|
||
},
|
||
/// Live blend: the key objects (paint order) plus spacing/orientation/spine; the intermediate
|
||
/// steps are evaluated on demand (`live::blend_expand`).
|
||
Blend {
|
||
children: Vec<Arc<Node>>,
|
||
#[serde(default)]
|
||
spec: BlendSpec,
|
||
},
|
||
/// Live envelope distortion of `content`.
|
||
Envelope {
|
||
content: Vec<Arc<Node>>,
|
||
kind: EnvelopeKind,
|
||
/// Envelope Options → Fidelity (0–100).
|
||
#[serde(default = "crate::live::default_fidelity")]
|
||
fidelity: f64,
|
||
/// Edit Contents mode (the content, not the envelope, is edited).
|
||
#[serde(default)]
|
||
editing: bool,
|
||
},
|
||
/// Gradient mesh.
|
||
Mesh(GradientMesh),
|
||
/// Live Repeat (radial / grid / mirror) of source art.
|
||
Repeat(RepeatSpec),
|
||
}
|
||
|
||
fn yes() -> bool {
|
||
true
|
||
}
|
||
fn one() -> f32 {
|
||
1.0
|
||
}
|
||
|
||
/// One object in the tree. Children are `Arc`s so documents share structure between undo states.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct Node {
|
||
pub id: NodeId,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub name: Option<String>,
|
||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||
pub visible: bool,
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
pub locked: bool,
|
||
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
|
||
pub opacity: f32,
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
pub blend: BlendMode,
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
pub isolate: bool,
|
||
/// Knockout Group: whether a container's children knock each other out (see [`Knockout`]).
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
pub knockout: Knockout,
|
||
/// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and
|
||
/// opacity mask scale how much of the objects below it in the group it knocks out.
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
pub knockout_shape: bool,
|
||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||
pub appearance: Appearance,
|
||
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub mask: Option<Box<OpacityMask>>,
|
||
/// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub trace: Option<Box<serde_json::Value>>,
|
||
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub wrap: Option<crate::text::TextWrap>,
|
||
/// Graph object: the group's children are generated from this spec (Object → Graph).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub graph: Option<Box<crate::graph::GraphSpec>>,
|
||
pub kind: NodeKind,
|
||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||
/// that style's look: editing its appearance or transparency breaks the link.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub graphic_style: Option<u32>,
|
||
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub attrs: Option<Box<ObjectAttributes>>,
|
||
}
|
||
|
||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct OpacityMask {
|
||
/// Mask art in document coordinates.
|
||
pub art: Arc<Node>,
|
||
/// Clip: outside the mask art is hidden. Off: outside the mask art stays visible.
|
||
#[serde(default = "yes")]
|
||
pub clip: bool,
|
||
/// Invert the mask's luminance.
|
||
#[serde(default)]
|
||
pub invert: bool,
|
||
/// Disabled masks are kept but not applied.
|
||
#[serde(default)]
|
||
pub disabled: bool,
|
||
/// Linked masks move with the object.
|
||
#[serde(default = "yes")]
|
||
pub linked: bool,
|
||
}
|
||
|
||
impl OpacityMask {
|
||
pub fn new(art: Node, clip: bool) -> Self {
|
||
Self { art: Arc::new(art), clip, invert: false, disabled: false, linked: true }
|
||
}
|
||
}
|
||
|
||
impl Node {
|
||
pub fn new(id: NodeId, kind: NodeKind) -> Self {
|
||
Self {
|
||
id,
|
||
name: None,
|
||
visible: true,
|
||
locked: false,
|
||
opacity: 1.0,
|
||
blend: BlendMode::Normal,
|
||
isolate: false,
|
||
knockout: Knockout::Neutral,
|
||
knockout_shape: false,
|
||
appearance: Appearance::default(),
|
||
mask: None,
|
||
trace: None,
|
||
wrap: None,
|
||
graph: None,
|
||
kind,
|
||
graphic_style: None,
|
||
attrs: None,
|
||
}
|
||
}
|
||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||
let mut n = Self::new(id, NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false });
|
||
n.appearance = appearance;
|
||
n
|
||
}
|
||
pub fn group(id: NodeId, children: Vec<Arc<Node>>) -> Self {
|
||
Self::new(id, NodeKind::Group { children, clip: false })
|
||
}
|
||
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
|
||
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false });
|
||
n.name = Some(name.to_string());
|
||
n
|
||
}
|
||
pub fn children(&self) -> Option<&Vec<Arc<Node>>> {
|
||
match &self.kind {
|
||
NodeKind::Layer { children, .. }
|
||
| NodeKind::Group { children, .. }
|
||
| NodeKind::Compound { children, .. }
|
||
| NodeKind::Blend { children, .. }
|
||
| NodeKind::Envelope { content: children, .. }
|
||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn children_mut(&mut self) -> Option<&mut Vec<Arc<Node>>> {
|
||
match &mut self.kind {
|
||
NodeKind::Layer { children, .. }
|
||
| NodeKind::Group { children, .. }
|
||
| NodeKind::Compound { children, .. }
|
||
| NodeKind::Blend { children, .. }
|
||
| NodeKind::Envelope { content: children, .. }
|
||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn is_layer(&self) -> bool {
|
||
matches!(self.kind, NodeKind::Layer { .. })
|
||
}
|
||
pub fn is_container(&self) -> bool {
|
||
self.children().is_some()
|
||
}
|
||
/// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel
|
||
/// fills an object's target circle otherwise).
|
||
pub fn has_default_transparency(&self) -> bool {
|
||
self.opacity >= 1.0
|
||
&& self.blend == BlendMode::Normal
|
||
&& !self.isolate
|
||
&& self.knockout == Knockout::Neutral
|
||
&& !self.knockout_shape
|
||
&& self.mask.is_none()
|
||
}
|
||
/// Kind name as the Layers panel / Properties panel shows it.
|
||
pub fn kind_label(&self) -> &'static str {
|
||
match &self.kind {
|
||
NodeKind::Layer { .. } => "Layer",
|
||
NodeKind::Group { clip: true, .. } => "Clip Group",
|
||
NodeKind::Group { .. } => "Group",
|
||
NodeKind::Path { live: Some(l), .. } => l.label(),
|
||
NodeKind::Path { guide: true, .. } => "Guide",
|
||
NodeKind::Path { .. } => "Path",
|
||
NodeKind::Compound { .. } => "Compound Path",
|
||
NodeKind::Text(_) => "Type",
|
||
NodeKind::Image(_) => "Image",
|
||
NodeKind::SymbolInstance { .. } => "Symbol",
|
||
NodeKind::Blend { .. } => "Blend",
|
||
NodeKind::Envelope { .. } => "Envelope",
|
||
NodeKind::Mesh(_) => "Mesh",
|
||
NodeKind::Repeat(r) => r.kind.label(),
|
||
}
|
||
}
|
||
/// Name shown in the Layers panel: explicit name or `<Kind>`.
|
||
pub fn display_name(&self) -> String {
|
||
if let Some(n) = &self.name {
|
||
return n.clone();
|
||
}
|
||
match &self.kind {
|
||
NodeKind::Text(t) => {
|
||
let s: String = t.plain_text().chars().take(32).collect();
|
||
if s.is_empty() { "<Text>".into() } else { s }
|
||
}
|
||
_ => format!("<{}>", self.kind_label()),
|
||
}
|
||
}
|
||
/// Path data for path nodes.
|
||
pub fn path_data(&self) -> Option<&PathData> {
|
||
match &self.kind {
|
||
NodeKind::Path { path, .. } => Some(path),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn path_data_mut(&mut self) -> Option<&mut PathData> {
|
||
match &mut self.kind {
|
||
NodeKind::Path { path, .. } => Some(path),
|
||
_ => None,
|
||
}
|
||
}
|
||
/// The path a path's or compound path's own strokes follow (a compound's members' together).
|
||
pub fn stroke_path(&self) -> Option<vectorcraft_geom::BezPath> {
|
||
match &self.kind {
|
||
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
|
||
NodeKind::Compound { children, .. } => {
|
||
let mut bp = vectorcraft_geom::BezPath::new();
|
||
children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath()));
|
||
Some(bp)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
/// A quick box round everything the object can paint, for culling clicks: paths and compound
|
||
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
|
||
/// ([`Appearance::outset`]) instead of measuring along their shape.
|
||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||
match &self.kind {
|
||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||
let o = self.appearance.outset();
|
||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||
}
|
||
_ => self.visual_bounds(),
|
||
}
|
||
}
|
||
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
|
||
pub fn geometric_bounds(&self) -> Option<Rect> {
|
||
match &self.kind {
|
||
NodeKind::Path { path, .. } => path.bounds(),
|
||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||
children.first().and_then(|c| c.geometric_bounds())
|
||
}
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
|
||
.iter()
|
||
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
|
||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
|
||
NodeKind::Text(t) => t.bounds(),
|
||
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
|
||
NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))),
|
||
NodeKind::Blend { children, spec } => {
|
||
let b = crate::live::nodes_bounds(children);
|
||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
|
||
}
|
||
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
|
||
NodeKind::Mesh(m) => m.bounds(),
|
||
NodeKind::Repeat(r) => r.bounds(),
|
||
}
|
||
}
|
||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
|
||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||
match &self.kind {
|
||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
|
||
let clip = children.first()?;
|
||
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
|
||
clip.visual_bounds()
|
||
} else {
|
||
clip.geometric_bounds()
|
||
}
|
||
}
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||
// The container's own strokes paint around its members.
|
||
let o = self.appearance.outset();
|
||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o))
|
||
}
|
||
NodeKind::Blend { children, spec } => {
|
||
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
|
||
let o = crate::live::max_outset(children);
|
||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
|
||
}
|
||
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
|
||
let o = crate::live::max_outset(content);
|
||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||
}
|
||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||
let b = self.geometric_bounds()?;
|
||
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
|
||
}
|
||
_ => {
|
||
let o = self.appearance.outset();
|
||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||
}
|
||
}
|
||
}
|
||
/// Apply an affine transform to the geometry (and gradients) of this node and its descendants.
|
||
/// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights
|
||
/// only, see [`Scaling`]).
|
||
pub fn transform(&mut self, a: Affine, scaling: impl Into<Scaling>) {
|
||
self.transform_scaled(a, &scaling.into());
|
||
}
|
||
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
|
||
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||
if !keeps_gradient_fit(a) {
|
||
self.pin_gradients();
|
||
}
|
||
let k = Scaling::factor(a);
|
||
if let Some(k) = k
|
||
&& sc.strokes
|
||
{
|
||
self.appearance.scale_strokes(k);
|
||
if let Some(f) = sc.effects {
|
||
self.appearance.scale_effects(k, f);
|
||
}
|
||
}
|
||
self.appearance.transform_gradients(a);
|
||
match &mut self.kind {
|
||
NodeKind::Path { path, live, .. } => {
|
||
path.transform(a);
|
||
if let Some(l) = live {
|
||
l.transform(a);
|
||
if sc.keep_corners
|
||
&& let Some(k) = k
|
||
&& l.keep_corners(k)
|
||
{
|
||
*path = l.to_path();
|
||
}
|
||
}
|
||
}
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||
for c in children.iter_mut() {
|
||
Arc::make_mut(c).transform_scaled(a, sc);
|
||
}
|
||
}
|
||
NodeKind::Text(t) => {
|
||
t.transform(a);
|
||
// Character strokes scale with the type: undo that when strokes keep their weight.
|
||
if sc.keep_type_strokes
|
||
&& let Some(k) = k.filter(|k| *k > 1e-12)
|
||
{
|
||
t.scale_char_strokes(1.0 / k);
|
||
}
|
||
}
|
||
NodeKind::Image(im) => im.xf = a * im.xf,
|
||
NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf,
|
||
NodeKind::Blend { children, spec } => {
|
||
for c in children.iter_mut() {
|
||
Arc::make_mut(c).transform_scaled(a, sc);
|
||
}
|
||
if let Some(s) = &mut spec.spine {
|
||
s.transform(a);
|
||
}
|
||
}
|
||
NodeKind::Envelope { content, kind, .. } => {
|
||
for c in content.iter_mut() {
|
||
Arc::make_mut(c).transform_scaled(a, sc);
|
||
}
|
||
match kind {
|
||
EnvelopeKind::Mesh { points, .. } => {
|
||
for p in points.iter_mut() {
|
||
*p = a * *p;
|
||
}
|
||
}
|
||
EnvelopeKind::TopObject { path } => path.transform(a),
|
||
EnvelopeKind::Warp { .. } => {}
|
||
}
|
||
}
|
||
NodeKind::Mesh(m) => m.transform(a),
|
||
NodeKind::Repeat(r) => r.transform(a, *sc),
|
||
}
|
||
if let Some(m) = &mut self.mask
|
||
&& m.linked
|
||
{
|
||
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
|
||
}
|
||
}
|
||
/// Visit this node and all descendants depth first (paint order).
|
||
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
|
||
f(self);
|
||
if let Some(ch) = self.children() {
|
||
for c in ch {
|
||
c.walk(f);
|
||
}
|
||
}
|
||
}
|
||
/// Count of nodes in this subtree.
|
||
pub fn count(&self) -> usize {
|
||
let mut n = 0;
|
||
self.walk(&mut |_| n += 1);
|
||
n
|
||
}
|
||
/// Fix this node's unplaced gradients to their fit on its current bounds (before a transform
|
||
/// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed.
|
||
pub fn pin_gradients(&mut self) {
|
||
if self.appearance.has_unplaced_gradient()
|
||
&& let Some(b) = self.geometric_bounds()
|
||
{
|
||
self.appearance.pin_gradients(b);
|
||
}
|
||
}
|
||
/// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and
|
||
/// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active
|
||
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
|
||
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
|
||
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
|
||
/// geometric bounds. Strokes fit the box grown by half their weight.
|
||
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
|
||
let item = self.appearance.item_of_kind(item, !stroke);
|
||
if item.is_none()
|
||
&& let NodeKind::Text(t) = &self.kind
|
||
{
|
||
let st = &t.runs.first()?.style;
|
||
let b = t.local_bounds();
|
||
return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) });
|
||
}
|
||
// An object without bounds (an empty path) still shows its paints, fitted to an empty box.
|
||
let b = self.geometric_bounds().unwrap_or(Rect::ZERO);
|
||
if stroke {
|
||
self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b)))
|
||
} else {
|
||
self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b))
|
||
}
|
||
}
|
||
/// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates
|
||
/// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits.
|
||
pub fn proxy_gradient(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> {
|
||
let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
|
||
let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
|
||
let mut geom = g.resolve(b);
|
||
if to_doc != Affine::IDENTITY {
|
||
geom.transform(to_doc, g.gradient.kind);
|
||
}
|
||
Some((g, geom))
|
||
}
|
||
/// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates
|
||
/// (the automatic ones while unplaced), and the length its spreads are fractions of there
|
||
/// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits.
|
||
pub fn proxy_freeform(&self, stroke: bool, item: Option<usize>) -> Option<(vectorcraft_color::Freeform, f64)> {
|
||
let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
|
||
let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
|
||
if g.gradient.kind != vectorcraft_color::GradientKind::Freeform {
|
||
return None;
|
||
}
|
||
let mut f = g.freeform_on(b).into_owned();
|
||
let mut scale = vectorcraft_color::freeform::spread_scale(b);
|
||
if to_doc != Affine::IDENTITY {
|
||
f.transform(to_doc);
|
||
scale *= to_doc.determinant().abs().sqrt();
|
||
}
|
||
Some((f, scale))
|
||
}
|
||
}
|
||
|
||
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans
|
||
/// live above this crate (`vectorcraft-pathops`), so callers supply it.
|
||
pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath;
|
||
|
||
impl Node {
|
||
/// The filled regions this object clips to as the clipping path of a clip group, in document
|
||
/// space, each with its fill rule: a path, a compound path (with its holes), an image's frame,
|
||
/// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible
|
||
/// members of a group (their union) and the clipping path of a nested clip group. Live objects
|
||
/// are evaluated first. Guides and symbol instances add nothing.
|
||
pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> {
|
||
let mut out = vec![];
|
||
self.push_clip_shapes(text, &mut out);
|
||
out
|
||
}
|
||
|
||
fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) {
|
||
match &self.kind {
|
||
NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {}
|
||
NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)),
|
||
NodeKind::Compound { children, rule } => {
|
||
let mut bp = BezPath::new();
|
||
for p in children.iter().filter_map(|c| c.path_data()) {
|
||
bp.extend(p.to_bezpath());
|
||
}
|
||
out.push((bp, *rule));
|
||
}
|
||
NodeKind::Group { children, clip: true } => {
|
||
if let Some(c) = children.first() {
|
||
c.push_clip_shapes(text, out);
|
||
}
|
||
}
|
||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||
for c in children.iter().filter(|c| c.visible) {
|
||
c.push_clip_shapes(text, out);
|
||
}
|
||
}
|
||
NodeKind::Text(_) => {
|
||
if let Some(o) = text.and_then(|f| f(self)) {
|
||
o.push_clip_shapes(None, out);
|
||
}
|
||
}
|
||
NodeKind::Image(im) => {
|
||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||
out.push((frame.to_bezpath(), FillRule::NonZero));
|
||
}
|
||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared
|
||
/// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its
|
||
/// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to
|
||
/// clip by: the clipped art is then hidden.
|
||
pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> {
|
||
let mut shapes = self.clip_shapes(text);
|
||
match shapes.len() {
|
||
0 => None,
|
||
1 => shapes.pop(),
|
||
_ => Some((unite(&shapes), FillRule::NonZero)),
|
||
}
|
||
}
|
||
|
||
/// What this object paints as the clipping path of a clip group, shared by the renderer and
|
||
/// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted
|
||
/// over it (unclipped). Each part is this object keeping only those appearance items (type:
|
||
/// also only its characters' fills or strokes); `None` where it paints nothing, as right after
|
||
/// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths.
|
||
pub fn clip_paint(&self) -> ClipPaint {
|
||
use crate::appearance::AppearanceItem;
|
||
let runs = match &self.kind {
|
||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None,
|
||
NodeKind::Text(t) => Some(&t.runs),
|
||
_ => return ClipPaint::default(),
|
||
};
|
||
let paints = |fill: bool| {
|
||
let item = |i: &AppearanceItem| match i {
|
||
AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(),
|
||
AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0,
|
||
};
|
||
let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 };
|
||
self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run))
|
||
};
|
||
let part = |fill: bool| {
|
||
paints(fill).then(|| {
|
||
let mut n = self.clone();
|
||
for i in (0..n.appearance.items.len()).rev() {
|
||
if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill {
|
||
n.appearance.remove_item(i);
|
||
}
|
||
}
|
||
if let NodeKind::Text(t) = &mut n.kind {
|
||
for r in &mut t.runs {
|
||
*(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None;
|
||
}
|
||
}
|
||
n
|
||
})
|
||
};
|
||
ClipPaint { fill: part(true), stroke: part(false) }
|
||
}
|
||
}
|
||
|
||
/// What a clipping path paints ([`Node::clip_paint`]).
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct ClipPaint {
|
||
/// The clipping path with only its fills: painted behind the clipped art (inside the clip).
|
||
pub fill: Option<Node>,
|
||
/// The clipping path with only its strokes: painted over the clipped art, not clipped.
|
||
pub stroke: Option<Node>,
|
||
}
|
||
|
||
/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a
|
||
/// knockout group each child composites against the group's backdrop, so it hides the children
|
||
/// below it instead of showing them through its transparency. Neutral passes the enclosing
|
||
/// group's setting through to the children. Files from before the three states stored a bool:
|
||
/// `true` loads as On, `false` as Neutral.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub enum Knockout {
|
||
#[default]
|
||
Neutral,
|
||
Off,
|
||
On,
|
||
}
|
||
|
||
impl Knockout {
|
||
pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On];
|
||
|
||
/// Name in files and command parameters.
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Knockout::Neutral => "neutral",
|
||
Knockout::Off => "off",
|
||
Knockout::On => "on",
|
||
}
|
||
}
|
||
/// A name ([`Self::label`], any case) or a bool as older files and commands wrote it.
|
||
pub fn from_value(v: &serde_json::Value) -> Option<Self> {
|
||
match v {
|
||
serde_json::Value::Bool(b) => Some(Self::from(*b)),
|
||
serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())),
|
||
_ => None,
|
||
}
|
||
}
|
||
/// The state a click on the checkbox moves to: on → neutral → off → on.
|
||
pub fn cycle(self) -> Self {
|
||
match self {
|
||
Knockout::On => Knockout::Neutral,
|
||
Knockout::Neutral => Knockout::Off,
|
||
Knockout::Off => Knockout::On,
|
||
}
|
||
}
|
||
/// Whether children of a group with this state knock each other out, inside a group (or page)
|
||
/// where that is `enclosing`.
|
||
pub fn resolve(self, enclosing: bool) -> bool {
|
||
match self {
|
||
Knockout::Neutral => enclosing,
|
||
Knockout::Off => false,
|
||
Knockout::On => true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<bool> for Knockout {
|
||
fn from(b: bool) -> Self {
|
||
if b { Knockout::On } else { Knockout::Neutral }
|
||
}
|
||
}
|
||
|
||
impl Serialize for Knockout {
|
||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||
s.serialize_str(self.label())
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for Knockout {
|
||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||
let v = serde_json::Value::deserialize(d)?;
|
||
Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}")))
|
||
}
|
||
}
|
||
|
||
impl Node {
|
||
/// Whether the children of this group or layer knock each other out, inside a group (or page)
|
||
/// where that is `enclosing`.
|
||
pub fn knocks_out(&self, enclosing: bool) -> bool {
|
||
matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing)
|
||
}
|
||
|
||
/// A plain group or layer that is neutral to knockout and has no transparency or appearance
|
||
/// of its own: inside a knockout group its children composite as the group's own children.
|
||
pub fn passes_knockout_through(&self) -> bool {
|
||
matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. })
|
||
&& self.has_default_transparency()
|
||
&& self.appearance.items.is_empty()
|
||
&& self.appearance.effects.is_empty()
|
||
}
|
||
|
||
/// The visible elements of a knockout group made of `children` (bottom first): neutral plain
|
||
/// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves.
|
||
pub fn knockout_elements(children: &[Arc<Node>]) -> Vec<&Arc<Node>> {
|
||
fn push<'a>(children: &'a [Arc<Node>], out: &mut Vec<&'a Arc<Node>>) {
|
||
for c in children.iter().filter(|c| c.visible) {
|
||
match c.children() {
|
||
Some(ch) if c.passes_knockout_through() => push(ch, out),
|
||
_ => out.push(c),
|
||
}
|
||
}
|
||
}
|
||
let mut out = Vec::with_capacity(children.len());
|
||
push(children, &mut out);
|
||
out
|
||
}
|
||
|
||
/// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest.
|
||
pub fn clips(&self) -> bool {
|
||
matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })
|
||
}
|
||
|
||
/// Make this group or layer clip its children by its first child, or stop it.
|
||
pub fn set_clips(&mut self, on: bool) {
|
||
if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind {
|
||
*clip = on;
|
||
}
|
||
}
|
||
|
||
/// Whether blending inside this object reaches the art below it unless the object isolates
|
||
/// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke),
|
||
/// directly or through children that don't isolate their own blending.
|
||
pub fn blends_through(&self) -> bool {
|
||
self.blends_through_with(&mut |c| c.blends_through())
|
||
}
|
||
|
||
/// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache).
|
||
pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
|
||
match self.children() {
|
||
Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner),
|
||
_ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal),
|
||
}
|
||
}
|
||
|
||
/// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child).
|
||
pub fn children_blend(children: &[Arc<Node>], inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
|
||
children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c))))
|
||
}
|
||
}
|
||
|
||
impl Node {
|
||
/// The Appearance panel's row for what this object's own fills and strokes paint around:
|
||
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
|
||
/// whose fills and strokes paint their own geometry.
|
||
pub fn contents_label(&self) -> Option<&'static str> {
|
||
match self.kind {
|
||
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
|
||
NodeKind::Text(_) => Some("Characters"),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<bool>,
|
||
/// 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 `<a href>`; 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> {
|
||
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<R>(&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
|
||
}
|
||
}
|
||
|
||
impl Node {
|
||
/// Whether this object shows any transparency, itself or anything inside it: object, fill or
|
||
/// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient
|
||
/// stops. Hidden objects show none.
|
||
pub fn shows_transparency(&self) -> bool {
|
||
use vectorcraft_color::Paint;
|
||
let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0));
|
||
let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible);
|
||
self.visible
|
||
&& (self.opacity < 1.0
|
||
|| self.blend != BlendMode::Normal
|
||
|| self.mask.as_ref().is_some_and(|m| !m.disabled)
|
||
|| effects(&self.appearance.effects)
|
||
|| self
|
||
.appearance
|
||
.items
|
||
.iter()
|
||
.any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects())))
|
||
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency())))
|
||
}
|
||
}
|
||
|
||
/// 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();
|
||
let eps = 1e-12 * m0.abs().max(1.0);
|
||
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
|
||
}
|
||
|
||
impl Node {
|
||
/// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true
|
||
/// everywhere for objects without them, such as type, whose boxes stand in).
|
||
pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> {
|
||
let shapes = self.clip_shapes(None);
|
||
move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn rect_live_shape_roundtrip() {
|
||
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], xf: Affine::translate((5.0, 5.0)) };
|
||
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
|
||
assert_eq!(l.label(), "Rectangle");
|
||
}
|
||
|
||
#[test]
|
||
fn default_transparency() {
|
||
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||
assert!(p().has_default_transparency());
|
||
let tweaks: [fn(&mut Node); 7] = [
|
||
|n| n.opacity = 0.5,
|
||
|n| n.blend = BlendMode::Multiply,
|
||
|n| n.isolate = true,
|
||
|n| n.knockout = Knockout::On,
|
||
|n| n.knockout = Knockout::Off,
|
||
|n| n.knockout_shape = true,
|
||
|n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
|
||
];
|
||
for tweak in tweaks {
|
||
let mut n = p();
|
||
tweak(&mut n);
|
||
assert!(!n.has_default_transparency(), "{n:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn transform_group_recurses() {
|
||
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||
let mut g = Node::group(NodeId(1), vec![Arc::new(p)]);
|
||
g.transform(Affine::scale(2.0), true);
|
||
assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0)));
|
||
let child = &g.children().unwrap()[0];
|
||
assert_eq!(child.appearance.stroke_width(), 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
|
||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||
let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||
let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0));
|
||
let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) {
|
||
(Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom),
|
||
_ => panic!("gradient fill and stroke"),
|
||
};
|
||
let mut n = fresh();
|
||
n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true);
|
||
assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting");
|
||
let mut n = fresh();
|
||
n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
|
||
let (f, s) = geoms(&n);
|
||
// Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical.
|
||
let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
|
||
want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
|
||
let f = f.unwrap();
|
||
assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}");
|
||
assert!((s.unwrap().length() - 102.0).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn display_names() {
|
||
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||
assert_eq!(p.display_name(), "<Path>");
|
||
let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0));
|
||
assert_eq!(l.display_name(), "Layer 1");
|
||
}
|
||
|
||
#[test]
|
||
fn visual_bounds_include_stroke() {
|
||
let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||
p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round;
|
||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||
}
|
||
}
|