mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge upstream/main into fix/2414-svg-classes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,11 @@ pub struct NodeExtras {
|
||||
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
|
||||
/// filled non-zero) where this object is drawn over the group's other members.
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
/// Object › Intertwine: as a member of an Intertwine group, the areas (document coordinates,
|
||||
/// filled non-zero) where this object is drawn over members that are themselves drawn over
|
||||
/// others there. Item `k` is the area of tier `k + 2`; [`NodeExtras::intertwine`] is tier 1
|
||||
/// (see [`crate::intertwine`]).
|
||||
pub intertwine_tiers: Vec<PathData>,
|
||||
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
|
||||
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
/// Object › Objects on Path: the group's path and the objects attached to it (see
|
||||
@@ -58,6 +63,9 @@ pub struct NodeExtras {
|
||||
pub compound_shape: bool,
|
||||
/// As a member of a compound shape, how this object combines with the members below it.
|
||||
pub shape_mode: crate::ShapeMode,
|
||||
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
|
||||
/// art (see [`crate::symbol_overrides`]); empty for none.
|
||||
pub symbol_overrides: Vec<crate::SymbolOverride>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -76,11 +84,13 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
perspective: None,
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
intertwine_tiers: Vec::new(),
|
||||
live_paint_gaps: None,
|
||||
objects_on_path: None,
|
||||
colorize: None,
|
||||
compound_shape: false,
|
||||
shape_mode: crate::ShapeMode::Add,
|
||||
symbol_overrides: Vec::new(),
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
|
||||
@@ -198,7 +198,8 @@ impl Node {
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
|
||||
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
|
||||
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box
|
||||
/// takes in where its spine can put its keys and steps.
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
self.reach(None)
|
||||
}
|
||||
@@ -234,6 +235,11 @@ impl Node {
|
||||
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
|
||||
}),
|
||||
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
|
||||
// keys and the spine.
|
||||
NodeKind::Blend { children, spec } => {
|
||||
vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols))
|
||||
}
|
||||
_ => self.visual(symbols),
|
||||
}
|
||||
}
|
||||
@@ -496,8 +502,13 @@ impl Node {
|
||||
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
|
||||
}
|
||||
// The area where an Intertwine member is drawn over the others moves with it.
|
||||
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
|
||||
area.transform(a);
|
||||
if let Some(e) = self.extras_if_any() {
|
||||
if let Some(area) = e.intertwine.as_mut() {
|
||||
area.transform(a);
|
||||
}
|
||||
for area in &mut e.intertwine_tiers {
|
||||
area.transform(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,6 +697,12 @@ impl Node {
|
||||
/// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient
|
||||
/// stops or a colorized image. Hidden objects show none.
|
||||
pub fn shows_transparency(&self) -> bool {
|
||||
self.shows_transparency_with(&|_| true)
|
||||
}
|
||||
|
||||
/// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize`
|
||||
/// accepts it, for files that write some colorized images in their colorize paint (EPS).
|
||||
pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> 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);
|
||||
@@ -693,14 +710,14 @@ impl Node {
|
||||
&& (self.opacity < 1.0
|
||||
|| self.blend != BlendMode::Normal
|
||||
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|
||||
|| (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some())
|
||||
|| (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self))
|
||||
|| 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())))
|
||||
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,35 @@ fn transform_group_recurses() {
|
||||
assert_eq!(child.appearance.stroke_width(), 2.0);
|
||||
}
|
||||
|
||||
/// A symbol instance in perspective keeps its picture moved by a transform, and loses its
|
||||
/// projection when no invertible map of finite numbers draws the moved picture.
|
||||
#[test]
|
||||
fn a_projection_follows_the_transform_or_is_removed() {
|
||||
use vectorcraft_geom::{Homography, Point};
|
||||
let instance = |h: Homography| {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY });
|
||||
n.extras_mut().perspective =
|
||||
Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) }));
|
||||
n
|
||||
};
|
||||
let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection);
|
||||
// A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down.
|
||||
let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]);
|
||||
let mut n = instance(big);
|
||||
n.transform(Affine::scale(10.0), false);
|
||||
let p = Point::new(3.0, 7.0);
|
||||
assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9);
|
||||
// A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0).
|
||||
let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
let mut n = instance(far);
|
||||
n.transform(Affine::scale(1e150), false);
|
||||
assert_eq!(projection(&n), None);
|
||||
// A transform that is not invertible leaves the projection as it is.
|
||||
let mut n = instance(far);
|
||||
n.transform(Affine::scale(0.0), false);
|
||||
assert_eq!(projection(&n), Some(far.to_array()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||||
@@ -455,6 +484,23 @@ fn objects_are_read_within_the_node_budget() {
|
||||
assert!(with_node_budget(3, read).is_ok());
|
||||
}
|
||||
|
||||
/// An Intertwine member's areas of tier 2 and up are written and read back, and each one counts
|
||||
/// against [`with_node_budget`] as an object does, so a file of many empty areas is refused.
|
||||
#[test]
|
||||
fn intertwine_tiers_are_read_within_the_node_budget() {
|
||||
let mut n = Node::group(NodeId(1), vec![]);
|
||||
let area = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
n.extras_mut().intertwine_tiers = vec![PathData::default(), area];
|
||||
let json = serde_json::to_string(&n).unwrap();
|
||||
assert!(json.contains("\"intertwine_tiers\""), "{json}");
|
||||
let read = || serde_json::from_str::<Node>(&json);
|
||||
assert_eq!(with_node_budget(3, read).unwrap(), n, "the object and its two areas fit a budget of three");
|
||||
let why = with_node_budget(2, read).unwrap_err().to_string();
|
||||
assert!(why.contains("more than 2 objects"), "{why}");
|
||||
let plain = serde_json::to_string(&Node::group(NodeId(1), vec![])).unwrap();
|
||||
assert!(!plain.contains("intertwine_tiers"), "an object without tiers writes none: {plain}");
|
||||
}
|
||||
|
||||
/// #3587: fields read before an object's `type` are buffered value by value within
|
||||
/// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers
|
||||
/// its own fields again without counting them twice. VectorCraft writes `type` first, which
|
||||
|
||||
@@ -46,11 +46,13 @@ impl Serialize for Node {
|
||||
e.perspective.is_none(),
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.intertwine_tiers.is_empty(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
e.objects_on_path.is_none(),
|
||||
e.colorize.is_none(),
|
||||
is_default(&e.compound_shape),
|
||||
is_default(&e.shape_mode),
|
||||
e.symbol_overrides.is_empty(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -85,11 +87,13 @@ impl Serialize for Node {
|
||||
field!("perspective", &e.perspective);
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("intertwine_tiers", &e.intertwine_tiers);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
field!("objects_on_path", &e.objects_on_path);
|
||||
field!("colorize", &e.colorize);
|
||||
field!("compound_shape", &e.compound_shape);
|
||||
field!("shape_mode", &e.shape_mode);
|
||||
field!("symbol_overrides", &e.symbol_overrides);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -123,11 +127,39 @@ pub fn with_node_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
|
||||
/// each counts once, and the id usually comes first, before the node's children are read.
|
||||
fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<NodeId, D::Error> {
|
||||
let id = NodeId::deserialize(d)?;
|
||||
count_one()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Count one object against [`with_node_budget`].
|
||||
fn count_one<E: serde::de::Error>() -> Result<(), E> {
|
||||
if let Some((left, max)) = NODES_LEFT.get() {
|
||||
let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?;
|
||||
let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("the document has more than {max} objects")))?;
|
||||
NODES_LEFT.set(Some((left, max)));
|
||||
}
|
||||
Ok(id)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol
|
||||
/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed
|
||||
/// file of repeated empty items stays small while they take memory once read.
|
||||
fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Vec<T>, D::Error> {
|
||||
struct Items<T>(std::marker::PhantomData<T>);
|
||||
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items<T> {
|
||||
type Value = Vec<T>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<T>, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(item) = seq.next_element::<T>()? {
|
||||
count_one()?;
|
||||
out.push(item);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Items(std::marker::PhantomData))
|
||||
}
|
||||
|
||||
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
|
||||
@@ -186,6 +218,8 @@ pub(super) struct NodeWire {
|
||||
dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
#[serde(default)]
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default, deserialize_with = "counted_list")]
|
||||
intertwine_tiers: Vec<PathData>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
#[serde(default)]
|
||||
@@ -196,6 +230,8 @@ pub(super) struct NodeWire {
|
||||
compound_shape: bool,
|
||||
#[serde(default)]
|
||||
shape_mode: crate::ShapeMode,
|
||||
#[serde(default, deserialize_with = "counted_list")]
|
||||
symbol_overrides: Vec<crate::SymbolOverride>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -215,11 +251,13 @@ impl From<NodeWire> for Node {
|
||||
perspective: w.perspective,
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
intertwine_tiers: w.intertwine_tiers,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
objects_on_path: w.objects_on_path,
|
||||
colorize: w.colorize,
|
||||
compound_shape: w.compound_shape,
|
||||
shape_mode: w.shape_mode,
|
||||
symbol_overrides: w.symbol_overrides,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
|
||||
Reference in New Issue
Block a user