diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 19c9ded0b..6b9d902ba 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -248,9 +248,20 @@ impl PatternDef { let ab = self.art_bounds().unwrap_or(self.tile).union(self.tile) - self.tile.origin().to_vec2(); let (sx, sy) = self.tile_type.steps(w, h); // Shifts of brick/hex rows or columns are < one tile. - let (i0, i1) = (((region.x0 - ab.x1 - w) / sx).floor() as i64 - 1, ((region.x1 - ab.x0 + w) / sx).ceil() as i64 + 1); - let (j0, j1) = (((region.y0 - ab.y1 - h) / sy).floor() as i64 - 1, ((region.y1 - ab.y0 + h) / sy).ceil() as i64 + 1); - if (i1 - i0).saturating_mul(j1 - j0) > 250_000 { + // A region whose tile indices do not fit in integers (or are not finite) gives none, as does + // one with more than 250,000 positions or none. + let index = |v: f64| (v.abs() < 9.0e18).then_some(v as i64); + let (Some(i0), Some(i1), Some(j0), Some(j1)) = ( + index(((region.x0 - ab.x1 - w) / sx).floor()), + index(((region.x1 - ab.x0 + w) / sx).ceil()), + index(((region.y0 - ab.y1 - h) / sy).floor()), + index(((region.y1 - ab.y0 + h) / sy).ceil()), + ) else { + return vec![]; + }; + let (i0, i1, j0, j1) = (i0 - 1, i1 + 1, j0 - 1, j1 + 1); + let (ni, nj) = (i1.saturating_sub(i0), j1.saturating_sub(j0)); + if ni < 1 || nj < 1 || ni.saturating_mul(nj) > 250_000 { return vec![]; } let mut out = vec![]; @@ -282,8 +293,15 @@ impl PatternDef { out } /// Instances covering `region` (document space) as plain nodes for a pattern painted with - /// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Expand. + /// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort + /// Pattern Fills; Expand uses [`Self::instances_in_with`]. pub fn instances_in(&self, paint_xf: Affine, region: Rect) -> Vec { + self.instances_in_with(paint_xf, region, false) + } + /// [`Self::instances_in`], each instance placed with `scaling` (what scales with `paint_xf` + /// besides geometry). + pub fn instances_in_with(&self, paint_xf: Affine, region: Rect, scaling: impl Into) -> Vec { + let scaling = scaling.into(); let inv = paint_xf.inverse(); let pr = inv.transform_rect_bbox(region); let mut out = vec![]; @@ -291,7 +309,7 @@ impl PatternDef { let m = paint_xf * self.instance_xf(o); for a in &self.art { let mut n = (**a).clone(); - n.transform(m, false); + place_art(&mut n, m, scaling); zero_ids(&mut n); out.push(n); } @@ -300,6 +318,31 @@ impl PatternDef { } } +/// Transform pattern art `n` by `m` with `scaling`, as a pattern places its art: the opacity masks +/// in it move with it, unlinked ones included (the canvas draws a tile's art and masks together). +pub fn place_art(n: &mut Node, m: Affine, scaling: impl Into) { + let scaling = scaling.into(); + n.transform(m, scaling); + place_unlinked_masks(n, m, scaling); +} + +/// Transform the art of the unlinked opacity masks of `n` and its descendants by `m`, the masks +/// inside mask art included. +fn place_unlinked_masks(n: &mut Node, m: Affine, scaling: crate::node::Scaling) { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + let linked = mask.linked; + let art = Arc::make_mut(&mut mask.art); + // A linked mask's art moved with `n`; an unlinked one's moves here. + if !linked { + art.transform(m, scaling); + } + place_unlinked_masks(art, m, scaling); + } + for c in n.children_mut().into_iter().flatten() { + place_unlinked_masks(Arc::make_mut(c), m, scaling); + } +} + fn zero_ids(n: &mut Node) { n.id = NodeId(0); if let Some(ch) = n.children_mut() { @@ -670,6 +713,18 @@ mod tests { assert!(close(*d2.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0)).last().unwrap(), Vec2::new(20.0, 10.0))); } + #[test] + fn a_region_too_large_to_count_has_no_offsets() { + // A pattern scaled by 1e-20 covers a 100 pt square with a region of 1e22 pt. + let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]); + assert!(def.offsets_covering(Rect::new(-1e22, -1e22, 1e22, 1e22)).is_empty()); + assert!(def.offsets_covering(Rect::new(f64::NEG_INFINITY, 0.0, f64::INFINITY, 10.0)).is_empty()); + // Far out, a region one tile wide and 1e12 pt tall still counts its rows. + assert!(def.offsets_covering(Rect::new(1e18, 0.0, 1e18, 1e12)).is_empty()); + let tiny = Affine::scale(1e-20); + assert!(def.instances_in(tiny, Rect::new(0.0, 0.0, 100.0, 100.0)).is_empty()); + } + #[test] fn instances_follow_paint_xf() { let def = PatternDef::new("p", vec![sq(100.0, 100.0, 10.0)]); @@ -679,6 +734,33 @@ mod tests { assert!(n.iter().all(|n| n.id == NodeId(0))); } + #[test] + fn instances_carry_unlinked_masks_with_them() { + let mut art = (*sq(100.0, 100.0, 10.0)).clone(); + let mut mask = crate::node::OpacityMask::new((*sq(100.0, 100.0, 5.0)).clone(), true); + mask.linked = false; + art.extras_mut().mask = Some(Box::new(mask)); + let def = PatternDef::new("p", vec![Arc::new(art)]); + for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) { + let (b, m) = (n.geometric_bounds().unwrap(), n.extras().mask.as_ref().unwrap()); + assert_eq!(m.art.geometric_bounds().unwrap().origin(), b.origin(), "the mask moves with its art"); + assert!(!m.linked, "the mask stays unlinked"); + } + // An unlinked mask inside a linked mask's art moves too. + let mut inner = (*sq(100.0, 100.0, 5.0)).clone(); + let mut nested = crate::node::OpacityMask::new((*sq(100.0, 100.0, 2.0)).clone(), true); + nested.linked = false; + inner.extras_mut().mask = Some(Box::new(nested)); + let mut art = (*sq(100.0, 100.0, 10.0)).clone(); + art.extras_mut().mask = Some(Box::new(crate::node::OpacityMask::new(inner, true))); + let def = PatternDef::new("p", vec![Arc::new(art)]); + for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) { + let outer = n.extras().mask.as_ref().unwrap(); + let nested = outer.art.extras().mask.as_ref().unwrap(); + assert_eq!(nested.art.geometric_bounds().unwrap().origin(), n.geometric_bounds().unwrap().origin()); + } + } + #[test] fn preview_offsets_count() { let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]); diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 8c1b18d5e..132e0cba0 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -513,7 +513,8 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an /// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path /// painted by that fill and every visible stroke without a brush its filled outline in the -/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as +/// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke: +/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as /// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None` /// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing. pub fn bake_appearance(n: &Node) -> Option { @@ -543,8 +544,28 @@ pub fn bake_appearance(n: &Node) -> Option { clear_item_effects(&mut it); pieces.push(match &it { AppearanceItem::Stroke(st) if st.brush.is_none() => { - let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) }; - piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill)) + let outline = crate::stroke::outline_region(&ig, rule, st); + match st.path_gradient() { + // A gradient along or across the stroke: its gradient meshes clipped to the outline. + Some(g) => { + let mut clip = Node::path(n.id, outline, Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let meshes = crate::stroke::gradient_meshes(&ig.to_bezpath(), rule, st, &g.gradient); + let meshes = meshes.into_iter().map(|m| Node::new(n.id, NodeKind::Mesh(m))); + let children = std::iter::once(clip).chain(meshes).map(Arc::new).collect(); + let mut group = Node::new(n.id, NodeKind::Group { children, clip: true }); + (group.opacity, group.blend) = (st.opacity, st.blend); + group.appearance.effects = st.effects.clone(); + Arc::new(group) + } + None => { + let effects = st.effects.clone(); + let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) }; + piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill)) + } + } } _ => piece(ig, rule, it), }); @@ -636,6 +657,36 @@ mod tests { assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50)); } + #[test] + fn a_baked_stroke_keeps_its_raster_effects() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + s.effects.push(fx("stylize.dropShadow", json!({}))); + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; + assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); + } + + #[test] + fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.width = 10.0; + s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); + s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; + s.opacity = 0.5; + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes"); + assert_eq!(ch[1].opacity, 0.5); + } + /// Black 24 pt "Type" with its baseline at (20, 60). fn type_node(id: u64) -> Node { let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index da1567ce7..9500cf625 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -1,8 +1,9 @@ -//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills -//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the +//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient and pattern +//! fills (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the //! art they show, their resources joining the document. A linear or radial gradient fill becomes a //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform -//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling. +//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip +//! group shaped like the object. Create Gradient Mesh shares the sampling. use std::sync::Arc; @@ -12,13 +13,14 @@ use vectorcraft_color::freeform::spread_scale; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::live::{GradientMesh, lerp_color}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::pattern::{PatternDef, place_art}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; -use super::pathops::{assemble, node_path, replace_leaves, styled_copy, with_transparency}; +use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency}; use super::*; const C: &str = "object.expand"; @@ -37,7 +39,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -46,11 +48,11 @@ pub fn specs() -> Vec { "Expand Options", [], None, - "{} what each Expand option would change in the selection → {object, fill, stroke} (true: something to expand)", + "{} what each Expand option would change in the selection → {object, fill, stroke, gradient} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to)", has_selection, |s, _| { let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); - Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke})) + Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient})) } ), ] @@ -99,6 +101,8 @@ struct Expandable { live: bool, effects: bool, fill: bool, + /// Gradient fills (the ones Expand Gradient To applies to), a part of `fill`. + gradient: bool, stroke: bool, /// An envelope (Object expands it into its distorted content). envelope: bool, @@ -127,10 +131,17 @@ impl Expandable { e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. }); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); - e.fill |= match &c.kind { + e.gradient |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), _ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()), }; + e.fill |= e.gradient; + e.fill |= match &c.kind { + NodeKind::Text(t) => { + t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())) + } + _ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()), + }; }); } e @@ -152,6 +163,17 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> { } } +/// The definition and placement of a visible pattern fill whose pattern `d` defines. +fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> { + match i { + AppearanceItem::Fill(f) if f.visible => match &f.paint { + Paint::Pattern { pattern, xf } => Some((f, d.pattern(pattern)?, *xf)), + _ => None, + }, + _ => None, + } +} + fn expand(s: &mut Session, p: &Value) -> Result { let object = bool_or(p, "object", true); let fill = bool_or(p, "fill", true); @@ -212,6 +234,7 @@ fn expand(s: &mut Session, p: &Value) -> Result { } if fill { let _ = expand_gradient_fills(s, to); + let _ = expand_pattern_fills(s); } if s.doc()?.revision == rev0 { return Err(EngineError::Other("Expand: nothing to expand".into())); @@ -250,18 +273,27 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> { } /// Path `l` with each gradient fill turned into art, in appearance order: the other items stay -/// on plain copies of the path. `None` when it has no gradient fill. +/// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own +/// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no +/// gradient fill can be expanded. fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) { return None; } + let reshaped = reshaped(l); + let l = reshaped.as_ref().unwrap_or(l); let (path, _) = node_path(l)?; let bounds = path.bounds()?; let mut parts = vec![]; let mut rest = vec![]; + let mut expanded = false; for item in &l.appearance.items { // Items that paint nothing go; a gradient that can't be expanded stays. - let Some(art) = gradient_fill(item).and_then(|(f, g)| gradient_art(d, l, &path, bounds, f, g, to)) else { + let art = gradient_fill(item).and_then(|(f, g)| { + let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?; + gradient_art(d, &fl, &path, bounds, f, g, to) + }); + let Some(art) = art else { if item.visible() && !item.paint().is_none() { rest.push(item.clone()); } @@ -271,6 +303,10 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { parts.push(styled_copy(d, l, std::mem::take(&mut rest))); } parts.push(art); + expanded = true; + } + if !expanded { + return None; } if !rest.is_empty() { parts.push(styled_copy(d, l, rest)); @@ -279,7 +315,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { } /// Gradient fill `f` (gradient `g`) of path `l` (outline `path`, box `bounds`) as art, with the -/// fill's opacity, blend mode and effects. +/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]). fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option { let content = if g.gradient.kind == GradientKind::Freeform { let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?; @@ -308,7 +344,377 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }) }; let mut art = with_transparency(d, art, f.opacity, f.blend); - art.appearance.effects = f.effects.clone(); + art.appearance.effects = fill_effects(f); + Some(art) +} + +/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its +/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no +/// visible geometry effect. +fn reshaped(l: &Node) -> Option { + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; + if !has_geometry(&l.appearance.effects) { + return None; + } + let (path, _) = node_path(l)?; + let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); + let mut m = with_path(l, g); + m.appearance.effects.retain(|e| !is_geometry(&e.id)); + Some(m) +} + +/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline +/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path` +/// as a plain path, and that outline's box. +fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> { + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry}; + if !has_geometry(&f.effects) { + return Some((l.clone(), path.clone(), bounds)); + } + let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l)); + let b = outline.bounds()?; + Some((with_path(l, outline.clone()), outline, b)) +} + +/// Path or compound path `l` with `path` as its path, a plain path. +fn with_path(l: &Node, path: PathData) -> Node { + let mut m = l.clone(); + m.kind = match &l.kind { + NodeKind::Path { rule, clipping, guide, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: *clipping, guide: *guide }, + NodeKind::Compound { rule, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false }, + k => k.clone(), + }; + m +} + +/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which +/// shape the outline it paints ([`fill_shape`]). +fn fill_effects(f: &FillLayer) -> Vec { + f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect() +} + +/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]). +fn expand_pattern_fills(s: &mut Session) -> Result<()> { + let roots = selected_roots(s)?; + s.edit("Expand", |d, sel| { + let (mut ids, mut changed) = (vec![], 0); + for r in &roots { + let (id, n) = replace_leaves(d, *r, expand_patterns)?; + ids.push(id); + changed += n; + } + if changed == 0 { + return Err(EngineError::Other("Expand: nothing to expand".into())); + } + sel.set(ids); + Ok(()) + }) +} + +/// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it +/// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects +/// other than geometry effects, in appearance order: the other items stay on plain copies of the +/// path. The path's geometry effects ([`reshaped`]) and each fill's own ([`fill_shape`]) shape +/// that outline first, as on the canvas. The tiles are drawn as the canvas draws them +/// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill +/// lays a tile. +fn expand_patterns(d: &mut Document, l: &Node) -> Option { + if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + return None; + } + let reshaped = reshaped(l); + let l = reshaped.as_ref().unwrap_or(l); + let (path, _) = node_path(l)?; + let bounds = path.bounds()?; + let mut parts = vec![]; + let mut rest = vec![]; + let mut expanded = false; + for item in &l.appearance.items { + let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf)); + let tiles = fill.and_then(|(f, def, xf)| { + let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?; + let t = pattern_tiles(d, &def, xf, bounds); + (!t.is_empty()).then_some((f, fl, t)) + }); + let Some(art) = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)) else { + if item.visible() && !item.paint().is_none() { + rest.push(item.clone()); + } + continue; + }; + if !rest.is_empty() { + parts.push(styled_copy(d, l, std::mem::take(&mut rest))); + } + parts.push(art); + expanded = true; + } + if !expanded { + return None; + } + if !rest.is_empty() { + parts.push(styled_copy(d, l, rest)); + } + assemble(d, l, parts) +} + +/// The tiles pattern `def` placed by `xf` lays over `bounds` in document `d`, as the canvas draws +/// them: their stroke weights and effects scale with the pattern, and when it also turns, flips, +/// stretches or shears them, the strokes (brushed ones included) and geometry effects of their +/// paths and opacity masks become filled art and their live shapes become plain paths first. The +/// same happens when it scales art with geometry effects (Roughen's detail, for one, is per inch +/// and does not scale). No tiles when it turns, flips, stretches or shears art with raster or 3D +/// effects or with a geometry effect on a group or type, or scales art with a geometry effect on a +/// group or type, which the canvas draws turned or scaled with the tile and live effects would +/// not, and when it does more than move art that holds a symbol instance, whose strokes the canvas +/// draws scaled with the tile and an instance does not scale. Random Transform effects and brushes +/// that vary at random in the art are baked first ([`seeds_baked`], [`random_brushes_baked`]). +fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect) -> Vec { + if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) { + return vec![]; + } + let Some(def) = seeds_baked(def) else { + return vec![]; + }; + let brushes = vectorcraft_brush::library(d); + let def = random_brushes_baked(d, &brushes, def); + let def = &*def; + let scaling = Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), patterns: true, ..Scaling::default() }; + if scales_evenly(xf) && (moves_only(xf) || !def.art.iter().any(|a| has_geometry_effects(a))) { + return def.instances_in_with(xf, bounds, scaling); + } + if def.art.iter().any(|a| has_unbakeable_effects(a)) { + return vec![]; + } + let placed = Scaling { patterns: true, ..Scaling::default() }; + let mut tiles = def.instances_in_with(Affine::IDENTITY, xf.inverse().transform_rect_bbox(bounds), placed); + for t in &mut tiles { + *t = prepared(d, &brushes, t); + place_art(t, xf, scaling); + } + tiles +} + +/// Pattern `def` with each path in its art (opacity masks included) that has a random Transform +/// effect baked into filled art ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)) +/// while it keeps its own id: the effect takes its seed from the id, and the canvas draws the art +/// with those ids for every tile, while the tiles get ids of their own. `None` when something +/// other than a path has such an effect. +fn seeds_baked(def: &PatternDef) -> Option> { + if !def.art.iter().any(|a| has_seeded(a)) { + return Some(std::borrow::Cow::Borrowed(def)); + } + let mut def = def.clone(); + def.art = def.art.iter().map(|a| bake_seeded(a).map(Arc::new)).collect::>()?; + Some(std::borrow::Cow::Owned(def)) +} + +/// Pattern `def` with each path in its art (opacity masks included) whose stroke has a brush that +/// varies at random ([`draws_random_art`]) [`prepared`] where the pattern defines it: the brush +/// takes its seed from the path's points, and the canvas draws the art there for every tile. +fn random_brushes_baked<'a>( + d: &mut Document, + brushes: &[vectorcraft_brush::Brush], + def: std::borrow::Cow<'a, PatternDef>, +) -> std::borrow::Cow<'a, PatternDef> { + if !def.art.iter().any(|a| has_random_brush(brushes, a)) { + return def; + } + let mut def = def.into_owned(); + def.art = def.art.iter().map(|a| Arc::new(bake_random_brushes(d, brushes, a))).collect(); + std::borrow::Cow::Owned(def) +} + +/// Does brush `name` of `brushes` draw different art for different points: a calligraphic or +/// scatter brush with Random variation, or a bristle brush? +fn draws_random_art(brushes: &[vectorcraft_brush::Brush], name: &str) -> bool { + use vectorcraft_brush::{BrushKind, Variation}; + brushes.iter().find(|b| b.name == name).is_some_and(|b| match &b.kind { + BrushKind::Calligraphic(c) => c.modes().contains(&Variation::Random), + BrushKind::Scatter(s) => s.modes().contains(&Variation::Random), + BrushKind::Bristle(_) => true, + BrushKind::Art(_) | BrushKind::Pattern(_) => false, + }) +} + +/// Is `n` a path with a visible stroke whose brush varies at random ([`draws_random_art`])? +fn random_brushed(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool { + let random = |st: &vectorcraft_doc::appearance::StrokeLayer| st.visible && st.brush.as_deref().is_some_and(|b| draws_random_art(brushes, b)); + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if random(st))) +} + +/// Does `n`, something in it or one of their opacity masks have a stroke whose brush varies at +/// random ([`random_brushed`])? +fn has_random_brush(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool { + random_brushed(brushes, n) + || n.extras().mask.as_ref().is_some_and(|m| has_random_brush(brushes, &m.art)) + || (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_random_brush(brushes, c)))) +} + +/// `n` with the paths in it whose brush varies at random [`prepared`] ([`random_brushes_baked`]). +fn bake_random_brushes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node { + if !has_random_brush(brushes, n) { + return n.clone(); + } + if random_brushed(brushes, n) { + return prepared(d, brushes, n); + } + let mut n = n.clone(); + if !matches!(n.kind, NodeKind::Compound { .. }) { + for c in n.children_mut().into_iter().flatten() { + *c = Arc::new(bake_random_brushes(d, brushes, c)); + } + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = Arc::new(bake_random_brushes(d, brushes, &m.art)); + } + n +} + +/// Does `n`, something in it or one of their opacity masks have a visible geometry effect? +fn has_geometry_effects(n: &Node) -> bool { + let geometry = |e: &vectorcraft_doc::Effect| e.visible && vectorcraft_render::effects::is_geometry(&e.id); + n.appearance.effects.iter().any(geometry) + || n.appearance.items.iter().any(|i| i.effects().iter().any(geometry)) + || n.extras().mask.as_ref().is_some_and(|m| has_geometry_effects(&m.art)) + || n.children().is_some_and(|c| c.iter().any(|c| has_geometry_effects(c))) +} + +/// Does `n`, something in it or one of their opacity masks hold a symbol instance? +fn has_instances(n: &Node) -> bool { + matches!(n.kind, NodeKind::SymbolInstance { .. }) + || n.extras().mask.as_ref().is_some_and(|m| has_instances(&m.art)) + || n.children().is_some_and(|c| c.iter().any(|c| has_instances(c))) +} + +/// Does `a` only move (no scale, turn, flip, stretch or shear)? +fn moves_only(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let eps = 1e-9; + (m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps +} + +/// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's? +fn seeded_here(n: &Node) -> bool { + let seeded = + |e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true); + n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded)) +} + +/// Does `n`, something in it or one of their opacity masks have a random Transform effect +/// ([`seeded_here`])? +fn has_seeded(n: &Node) -> bool { + seeded_here(n) + || n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art)) + || (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c)))) +} + +/// `n` with the paths in it that have a random Transform effect baked ([`seeds_baked`]). `None` +/// when something else has one. +fn bake_seeded(n: &Node) -> Option { + if !has_seeded(n) { + return Some(n.clone()); + } + let mut n = if seeded_here(n) { + if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + return None; + } + vectorcraft_render::effects::bake_appearance(n)? + } else { + n.clone() + }; + if !matches!(n.kind, NodeKind::Compound { .. }) { + for c in n.children_mut().into_iter().flatten() { + *c = Arc::new(bake_seeded(c)?); + } + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = Arc::new(bake_seeded(&m.art)?); + } + Some(n) +} + +/// Does `n`, something in it or one of their opacity masks have a visible effect that [`prepared`] +/// cannot bake: a raster or 3D effect, or a geometry effect on something other than a path? +fn has_unbakeable_effects(n: &Node) -> bool { + use vectorcraft_render::effects::{is_geometry, is_raster, is_three_d}; + let path = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + let unbakeable = |e: &vectorcraft_doc::Effect| e.visible && (is_raster(&e.id) || is_three_d(&e.id) || (!path && is_geometry(&e.id))); + n.appearance.effects.iter().any(unbakeable) + || n.appearance.items.iter().any(|i| i.effects().iter().any(unbakeable)) + || n.extras().mask.as_ref().is_some_and(|m| has_unbakeable_effects(&m.art)) + || n.children().is_some_and(|c| c.iter().any(|c| has_unbakeable_effects(c))) +} + +/// Tile art `n` ready for a turn, flip, stretch or shear: the strokes and geometry effects of its +/// paths baked ([`baked`], [`outline_brushed`]) and its live shapes turned into plain paths (a +/// stretch would rebuild their corners round), in its opacity masks too. +fn prepared(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node { + let mut t = baked(n); + outline_brushed(d, brushes, &mut t); + drop_live(&mut t); + prepare_masks(d, brushes, &mut t); + t +} + +/// The opacity masks of `n` and of everything in it [`prepared`]. +fn prepare_masks(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = Arc::new(prepared(d, brushes, &m.art)); + } + for c in n.children_mut().into_iter().flatten() { + prepare_masks(d, brushes, Arc::make_mut(c)); + } +} + +/// `n` with each path in it that has a brushed stroke turned into filled art and brush art +/// ([`outline_strokes`]). +fn outline_brushed(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + let brushed = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some())); + if brushed && let Some(o) = outline_strokes(d, brushes, n) { + *n = o; + } + return; + } + for c in n.children_mut().into_iter().flatten() { + outline_brushed(d, brushes, Arc::make_mut(c)); + } +} + +/// Is `a` a move and a positive uniform scale (which brush nibs, strokes and effects follow by +/// scaling alone)? +fn scales_evenly(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let eps = 1e-9 * m0.abs().max(m3.abs()); + m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps +} + +/// `n` with the strokes and geometry effects of each path in it baked into filled art +/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)). +fn baked(n: &Node) -> Node { + if let Some(b) = vectorcraft_render::effects::bake_appearance(n) { + return b; + } + let mut n = n.clone(); + for c in n.children_mut().into_iter().flatten() { + *c = Arc::new(baked(c)); + } + n +} + +/// Pattern tiles `tiles` of fill `f` of path `l` as a clip group shaped like `l` that isolates +/// their blending (as the canvas draws a tile on its own), with the fill's opacity, blend mode and +/// effects other than geometry effects ([`fill_effects`]). +fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option { + let mut clip = styled_copy(d, l, vec![]); + as_clipping_path(&mut clip).ok()?; + let tiles: Vec = tiles.iter().map(|t| d.reid(t)).collect(); + let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect(); + let mut art = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }); + art.isolate = true; + let mut art = with_transparency(d, art, f.opacity, f.blend); + art.appearance.effects = fill_effects(f); Some(art) } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index a4e68f07e..fe27f9424 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -582,9 +582,9 @@ pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, ble n } -/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the -/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode -/// and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped +/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art (with the +/// stroke's opacity, blend mode and raster effects), or the outline of the stroke (as on the canvas) +/// filled with the stroke's paint, opacity, blend mode and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped /// like the outline). `None` when it paints nothing. pub(crate) fn outlined_stroke( d: &mut Document, @@ -601,7 +601,15 @@ pub(crate) fn outlined_stroke( _ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()), }; let art = d.reid(&art); - return Some(with_transparency(d, art, st.opacity, st.blend)); + let mut art = with_transparency(d, art, st.opacity, st.blend); + let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect(); + if !raster.is_empty() { + if !art.appearance.effects.is_empty() { + art = Node::group(d.alloc_id(), vec![Arc::new(art)]); + } + art.appearance.effects = raster; + } + return Some(art); } let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st); if outline.is_empty() { @@ -673,12 +681,13 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec f } -/// What stands for object `l` once its appearance became `parts` (paint order): the one part or a -/// group of them, with `l`'s transparency, name, opacity mask and effects. `None` without parts. +/// What stands for object `l` once its appearance became `parts` (paint order): the one part (in a +/// group when it has effects of its own) or a group of them, with `l`'s transparency, name, opacity +/// mask and effects. `None` without parts. pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec) -> Option { let root = match parts.len() { 0 => return None, - 1 => parts.remove(0), + 1 if parts[0].appearance.effects.is_empty() => parts.remove(0), _ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()), }; let mut root = with_transparency(d, root, l.opacity, l.blend); diff --git a/crates/engine/src/tests_objexpand.rs b/crates/engine/src/tests_objexpand.rs index bcb9b117e..2be0547aa 100644 --- a/crates/engine/src/tests_objexpand.rs +++ b/crates/engine/src/tests_objexpand.rs @@ -1,10 +1,11 @@ //! Object → Expand (M3.84): the Object, Fill and Stroke options, gradient fills into solid strips, -//! concentric ellipses or a gradient mesh inside a clip group, freeform gradients into a mesh, and -//! Create Gradient Mesh taking a gradient fill's colours. +//! concentric ellipses or a gradient mesh inside a clip group, freeform gradients into a mesh, +//! pattern fills into their tiles inside a clip group, and Create Gradient Mesh taking a gradient +//! fill's colours. use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::Rect; use super::*; @@ -78,6 +79,35 @@ fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, .count() } +/// Pattern "Dots" (a red 10×10 square with a black 1 pt stroke, centered in a 20×20 tile) and a +/// selected rectangle filled with it, with a blue 4 pt stroke when `stroke`. +fn pattern_rect(s: &mut Session, r: Rect, stroke: bool) -> NodeId { + let dot = run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap(); + run(s, "paint.setFill", json!({"color": "#ff0000"})); + run(s, "paint.setStroke", json!({"color": "#000000"})); + run(s, "stroke.set", json!({"weight": 1})); + run(s, "object.pattern.make", json!({"ids": [dot], "name": "Dots", "width": 20, "height": 20, "edit": false})); + run(s, "select.set", json!({"ids": [dot]})); + run(s, "edit.clear", json!({})); + let id = run(s, "shape.rectangle", json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}))["id"].as_u64().unwrap(); + run(s, "paint.setFill", json!({"swatch": "Dots"})); + if stroke { + run(s, "paint.setStroke", json!({"color": "#0000ff"})); + run(s, "stroke.set", json!({"weight": 4})); + } else { + run(s, "paint.setStroke", json!({"none": true})); + } + NodeId(id) +} + +/// `object.expand` with every option on, for a selected rectangle with a pattern fill only: the +/// one selected object is then the clip group of its tiles. +fn expand_to_tiles(s: &mut Session) { + run(s, "object.expand", json!({})); + let g = selected(s); + assert!(!clip_parts(&g).1.is_empty(), "the pattern fill became tiles"); +} + fn white_black() -> Value { json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}) } @@ -196,6 +226,550 @@ fn a_freeform_gradient_expands_to_a_mesh_shaped_like_the_object() { assert!(mean_diff(&before, &after) < 2.5, "{}", mean_diff(&before, &after)); } +#[test] +fn a_pattern_fill_becomes_its_tiles_clipped_by_the_object() { + let mut s = session(); + pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false); + // Fill expands the pattern; Expand Gradient To has no gradient fill to apply to. + assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": false, "gradient": false})); + let before = render(&s); + let undo = s.doc().unwrap().history.undo.len(); + run(&mut s, "object.expand", json!({})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let g = selected(&s); + let (clip, tiles) = clip_parts(&g); + assert!(matches!(clip.kind, NodeKind::Path { clipping: true, live: None, .. })); + assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(25.0, 25.0, 85.0, 65.0)); + // The 20 pt tile is centered on the square, so the squares sit at 5 + 20k; the tiles that reach + // the rectangle are 4 across and 3 down. + assert_eq!(tiles.len(), 12); + for t in &tiles { + let b = t.geometric_bounds().unwrap(); + assert!((b.x0 - 5.0) % 20.0 == 0.0 && (b.y0 - 5.0) % 20.0 == 0.0 && b.width() == 10.0 && b.height() == 10.0, "{b:?}"); + assert_eq!(fill_color(t).to_hex(), "#ff0000"); + } + let mut ids = std::collections::HashSet::new(); + g.walk(&mut |n| assert!(ids.insert(n.id), "fresh ids: {:?}", n.id)); + assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same"); + // The tiles are the pattern's art as it is (a stroked live rectangle that Object and Stroke can + // expand). + assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": false, "stroke": true, "gradient": false})); + run(&mut s, "edit.undo", json!({})); + assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots")); + // Fill off: the pattern stays live. + run(&mut s, "object.expand", json!({"fill": false})); + assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Pattern { .. })); +} + +#[test] +fn the_tiles_of_a_scaled_pattern_keep_the_stroke_weight_the_canvas_draws() { + let mut s = session(); + let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false); + run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [2, 0, 0, 2, 0, 0]})); + let before = render(&s); + run(&mut s, "object.expand", json!({})); + let g = selected(&s); + let (_, tiles) = clip_parts(&g); + assert!(!tiles.is_empty()); + for t in &tiles { + assert_eq!(t.geometric_bounds().unwrap().width(), 20.0); + let Some(AppearanceItem::Stroke(st)) = t.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!() }; + assert_eq!(st.width, 2.0, "the 1 pt stroke drawn at 200%"); + } + assert!(mean_diff(&before, &render(&s)) < 1.0, "{}", mean_diff(&before, &render(&s))); +} + +#[test] +fn the_tiles_of_a_stretched_pattern_draw_their_strokes_stretched() { + let mut s = session(); + let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false); + // Twice as wide: the 1 pt stroke draws 2 pt wide on the sides and 1 pt on the top and bottom. + run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [2, 0, 0, 1, 0, 0]})); + let before = render(&s); + run(&mut s, "object.expand", json!({})); + let g = selected(&s); + let (_, tiles) = clip_parts(&g); + assert!(!tiles.is_empty()); + let mut strokes = 0; + for t in &tiles { + t.walk(&mut |n| strokes += n.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count()); + } + assert_eq!(strokes, 0, "the strokes became filled outlines"); + assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same"); +} + +#[test] +fn the_rounded_corners_of_a_stretched_pattern_stay_stretched() { + let mut s = session(); + let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "radius": 6}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Round", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [tile]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Round"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [3, 0, 0, 1, 0, 0]})); + let before = render(&s); + expand_to_tiles(&mut s); + // The canvas draws the tile as an image stretched 3×, so the edges differ a little; round + // corners would differ by far more. + assert_eq!(differing(&before, &render(&s), 64), 0, "corners stretched as the canvas draws them"); +} + +#[test] +fn a_gradient_along_a_tile_stroke_follows_the_stroke_in_a_stretched_pattern() { + let mut s = session(); + let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 14, "height": 14}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "stroke.set", json!({"weight": 4})); + run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": "along"})); + run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Along", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [tile]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Along"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 1, 0, 0]})); + let before = render(&s); + expand_to_tiles(&mut s); + // The meshes are drawn as small flat pieces and the canvas stretches the tile image, so the + // pictures are close but not identical; a gradient across the outline's box differs almost + // everywhere on the strokes. + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{}", mean_diff(&before, &after)); + assert!(differing(&before, &after, 64) < 500, "{}", differing(&before, &after, 64)); +} + +#[test] +fn a_brushed_tile_stroke_draws_its_brush_stretched() { + let mut s = session(); + let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"color": "#000000"})); + run(&mut s, "brush.apply", json!({"name": "6 pt. Flat"})); + run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Brushed", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [tile]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Brushed"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 1, 0, 0]})); + let before = render(&s); + expand_to_tiles(&mut s); + // The canvas stretches the tile image, so edges differ a little. + assert_eq!(differing(&before, &render(&s), 64), 0, "the brush draws stretched as on the canvas"); + let brushed = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()); + let mut left = 0; + selected(&s).walk(&mut |n| left += n.appearance.items.iter().filter(|i| brushed(i)).count()); + assert_eq!(left, 0, "the brush strokes became brush art"); +} + +#[test] +fn a_flat_brush_in_a_rotated_pattern_turns_with_it() { + let mut s = session(); + let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"color": "#000000"})); + run(&mut s, "brush.apply", json!({"name": "6 pt. Flat"})); + run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Flat", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [tile]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Flat"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + // A quarter turn: the nib's thick and thin sides swap with the tile. + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [0, 1, -1, 0, 0, 0]})); + let before = render(&s); + expand_to_tiles(&mut s); + assert_eq!(differing(&before, &render(&s), 64), 0, "the brush turns with the tiles as on the canvas"); +} + +#[test] +fn an_unlinked_opacity_mask_in_the_tile_art_moves_with_each_tile() { + let mut s = session(); + let red = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + // A white mask over the square's left half, unlinked from it. + let mask = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 10}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": "#ffffff"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "transparency.makeOpacityMask", json!({"ids": [red, mask]})); + run(&mut s, "transparency.unlinkOpacityMask", json!({"id": red})); + run(&mut s, "object.pattern.make", json!({"ids": [red], "name": "Half", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [red]})); + run(&mut s, "edit.clear", json!({})); + run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160})); + run(&mut s, "paint.setFill", json!({"swatch": "Half"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let before = render(&s); + expand_to_tiles(&mut s); + assert_eq!(differing(&before, &render(&s), 2), 0, "each tile keeps its half mask"); +} + +#[test] +fn a_rounded_mask_in_a_stretched_pattern_stays_stretched() { + let mut s = session(); + let red = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + // A white rounded square masks the red one. + let mask = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "radius": 6}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": "#ffffff"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "transparency.makeOpacityMask", json!({"ids": [red, mask]})); + run(&mut s, "object.pattern.make", json!({"ids": [red], "name": "Masked", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [red]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Masked"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [3, 0, 0, 1, 0, 0]})); + let before = render(&s); + expand_to_tiles(&mut s); + // As for rounded tiles: the canvas stretches the tile image, so edges differ a little. + assert_eq!(differing(&before, &render(&s), 64), 0, "the mask's corners stretched as on the canvas"); +} + +#[test] +fn a_turned_pattern_whose_tiles_have_effects_it_cannot_bake_stays_live() { + // A drop shadow on the square, and an Offset Path on a group around it: the canvas draws both + // turned with each tile. + for group in [false, true] { + let mut s = session(); + let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false); + run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [0, 1, -1, 0, 0, 0]})); + let square = s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].clone(); + let art = if group { + let mut g = Node::group(NodeId(0), vec![square]); + g.appearance.effects.push(vectorcraft_render::effects::new_effect("path.offsetPath", &json!({"offset": 2})).unwrap()); + g + } else { + let mut a = (*square).clone(); + a.appearance.effects.push(vectorcraft_render::effects::new_effect("stylize.dropShadow", &json!({"x": 4, "y": 0})).unwrap()); + a + }; + s.edit("Effect", |d, _| { + d.pattern_mut("Dots").unwrap().art = vec![std::sync::Arc::new(art)]; + Ok(()) + }) + .unwrap(); + assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "group {group}: nothing to expand"); + assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Pattern { .. })); + } +} + +#[test] +fn a_random_transform_in_the_tile_art_draws_every_tile_alike() { + // The canvas draws the pattern's art once, so each tile shows the same random move and turn: + // on the square as the pattern is, then turned a quarter, and on a group around the square, + // which Expand cannot bake and leaves live. + for (turned, group) in [(false, false), (true, false), (false, true)] { + let mut s = session(); + let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false); + let square = s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].clone(); + let mut art = if group { Node::group(NodeId(0), vec![square]) } else { (*square).clone() }; + let fx = json!({"moveH": 4, "rotate": 40, "random": true}); + art.appearance.effects.push(vectorcraft_render::effects::new_effect("distort.transform", &fx).unwrap()); + s.edit("Effect", |d, _| { + d.pattern_mut("Dots").unwrap().art = vec![std::sync::Arc::new(art)]; + Ok(()) + }) + .unwrap(); + if turned { + run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [0, 1, -1, 0, 0, 0]})); + } + if group { + assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "nothing to expand"); + assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Pattern { .. })); + continue; + } + let before = render(&s); + expand_to_tiles(&mut s); + // The turned squares' edges differ a little. + assert_eq!(differing(&before, &render(&s), 64), 0, "turned {turned}: every tile as on the canvas"); + } +} + +#[test] +fn a_random_brush_in_the_tile_art_draws_every_tile_alike() { + // Confetti scatters its art at random from the path's points; the canvas draws the pattern's + // art once, so each tile shows the same confetti, as the pattern is and turned a quarter. + for turned in [false, true] { + let mut s = session(); + let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"color": "#000000"})); + run(&mut s, "brush.apply", json!({"name": "Confetti"})); + run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Confetti", "width": 60, "height": 60, "edit": false})); + run(&mut s, "select.set", json!({"ids": [tile]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Confetti"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + if turned { + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [0, 1, -1, 0, 0, 0]})); + } + let before = render(&s); + expand_to_tiles(&mut s); + assert_eq!(differing(&before, &render(&s), 64), 0, "turned {turned}: every tile as on the canvas"); + } +} + +#[test] +fn roughen_in_the_tile_art_of_a_scaled_pattern_keeps_its_detail() { + // Roughen's detail is per inch: the canvas roughens the 10 pt square and scales the tile, so + // the expanded square has as many points as the canvas draws, not twice as many. + let mut s = session(); + let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false); + let mut art = (*s.doc().unwrap().doc.pattern("Dots").unwrap().art[0]).clone(); + let fx = json!({"size": 2, "relative": false, "detail": 20, "points": "corner", "seed": 3}); + art.appearance.effects.push(vectorcraft_render::effects::new_effect("distort.roughen", &fx).unwrap()); + s.edit("Effect", |d, _| { + d.pattern_mut("Dots").unwrap().art = vec![std::sync::Arc::new(art)]; + Ok(()) + }) + .unwrap(); + run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [2, 0, 0, 2, 0, 0]})); + let before = render(&s); + expand_to_tiles(&mut s); + let mut live = 0; + selected(&s).walk(&mut |n| live += n.appearance.effects.len()); + assert_eq!(live, 0, "Roughen became filled art"); + // The canvas scales the tile image 2×, so edges differ a little. + let after = render(&s); + assert!(mean_diff(&before, &after) < 1.0 && differing(&before, &after, 64) < 100, "every tile as on the canvas"); +} + +#[test] +fn a_scaled_pattern_whose_tiles_hold_a_symbol_stays_live() { + // The canvas draws the tile scaled, the symbol's 1 pt stroke included; a scaled instance keeps + // its stroke weight. A pattern that only moves expands. + for scaled in [false, true] { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"color": "#000000"})); + run(&mut s, "stroke.set", json!({"weight": 1})); + let inst = run(&mut s, "symbol.new", json!({"name": "Box"}))["id"].as_u64().unwrap(); + run(&mut s, "object.pattern.make", json!({"ids": [inst], "name": "Boxes", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [inst]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Boxes"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + if scaled { + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 2, 0, 0]})); + assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "nothing to expand"); + assert!(matches!(node(&s, NodeId(id)).appearance.fill_paint(), Paint::Pattern { .. })); + continue; + } + let before = render(&s); + expand_to_tiles(&mut s); + assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same"); + } +} + +#[test] +fn a_pattern_inside_a_stretched_pattern_moves_with_each_tile() { + let mut s = session(); + // "Inner": a 4 pt red square in an 8 pt tile; "Outer": a 10 pt square filled with Inner, in a + // 20 pt tile, so each Outer tile starts 4 pt into Inner's period from the one before it. + let dot = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "object.pattern.make", json!({"ids": [dot], "name": "Inner", "width": 8, "height": 8, "edit": false})); + let sq = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Inner"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "object.pattern.make", json!({"ids": [sq], "name": "Outer", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [dot, sq]})); + run(&mut s, "edit.clear", json!({})); + let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"swatch": "Outer"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 1, 0, 0]})); + let before = render(&s); + expand_to_tiles(&mut s); + assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same"); +} + +#[test] +fn a_fill_effect_stays_on_the_expanded_fill() { + let has_effect = |n: &Node| { + let mut found = false; + n.walk(&mut |c| found |= matches!(c.kind, NodeKind::Group { clip: true, .. }) && !c.appearance.effects.is_empty()); + found + }; + // A pattern fill and a gradient fill, each the object's only item, with a drop shadow. + let kept = [true, false].map(|pattern| { + let mut s = session(); + if pattern { + pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false); + } else { + gradient_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), white_black(), false); + } + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": 0})); + run(&mut s, "object.expand", json!({"steps": 4})); + has_effect(&selected(&s)) + }); + assert_eq!(kept, [true, true], "the fill's drop shadow is on its clip group"); +} + +#[test] +fn a_geometry_effect_shapes_the_clip_of_an_expanded_pattern_and_not_its_tiles() { + // Offset Path on the pattern fill, then on the object: either grows the area the fill paints by + // 6 pt on each side, and the canvas lays the tiles over that area unchanged. + for item in [true, false] { + let mut s = session(); + pattern_rect(&mut s, Rect::new(40.0, 40.0, 160.0, 160.0), false); + let mut p = json!({"effect": "path.offsetPath", "params": {"offset": 6}}); + if item { + p["item"] = json!(0); + } + run(&mut s, "effect.apply", p); + let before = render(&s); + run(&mut s, "object.expand", json!({"object": false, "stroke": false})); + let g = selected(&s); + assert!(g.appearance.effects.is_empty(), "item {item}: the offset is not left on the tiles: {:?}", g.appearance.effects); + let (clip, tiles) = clip_parts(&g); + let b = clip.geometric_bounds().unwrap(); + assert!((b.x0 - 34.0).abs() < 0.01 && (b.x1 - 166.0).abs() < 0.01, "item {item}: the clip is the offset outline: {b:?}"); + assert!(tiles.iter().all(|t| t.geometric_bounds().is_some_and(|b| (b.width() - 10.0).abs() < 0.01)), "item {item}: tiles keep their size"); + assert_eq!(differing(&before, &render(&s), 2), 0, "item {item}: looks the same"); + } +} + +#[test] +fn a_geometry_effect_shapes_the_clip_of_an_expanded_gradient_and_not_its_strips() { + // As for patterns: the canvas fits the gradient to the offset outline and fills it. + for item in [true, false] { + let mut s = session(); + gradient_rect(&mut s, Rect::new(40.0, 40.0, 160.0, 160.0), white_black(), false); + let mut p = json!({"effect": "path.offsetPath", "params": {"offset": 6}}); + if item { + p["item"] = json!(0); + } + run(&mut s, "effect.apply", p); + let before = render(&s); + run(&mut s, "object.expand", json!({"object": false, "stroke": false})); + let g = selected(&s); + assert!(g.appearance.effects.is_empty(), "item {item}: the offset is not left on the strips: {:?}", g.appearance.effects); + let b = clip_parts(&g).0.geometric_bounds().unwrap(); + assert!((b.x0 - 34.0).abs() < 0.01 && (b.x1 - 166.0).abs() < 0.01, "item {item}: the clip is the offset outline: {b:?}"); + assert_eq!(differing(&before, &render(&s), 2), 0, "item {item}: looks the same"); + } +} + +#[test] +fn tiles_that_blend_blend_only_with_each_other() { + let mut s = session(); + // A blue backdrop under a rectangle filled with a pattern whose square multiplies. + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + run(&mut s, "paint.setFill", json!({"color": "#0000ff"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let dot = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "transparency.set", json!({"blend": "multiply"})); + run(&mut s, "object.pattern.make", json!({"ids": [dot], "name": "Multiply", "width": 20, "height": 20, "edit": false})); + run(&mut s, "select.set", json!({"ids": [dot]})); + run(&mut s, "edit.clear", json!({})); + run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160})); + run(&mut s, "paint.setFill", json!({"swatch": "Multiply"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let before = render(&s); + run(&mut s, "object.expand", json!({})); + assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same"); + assert!(selected(&s).isolate, "the tiles' clip group isolates their blending"); +} + +#[test] +fn a_pattern_fill_that_lays_no_tiles_stays_live() { + // A 0.1 pt tile over a 160 pt square needs more instances than a pattern lays. + let mut s = session(); + let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false); + run(&mut s, "pattern.options", json!({"name": "Dots", "width": 0.1, "height": 0.1})); + // Fill alone: nothing to expand, and the live rectangle is left as it was. + assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err()); + assert!(matches!(node(&s, id).kind, NodeKind::Path { live: Some(_), .. })); + run(&mut s, "object.expand", json!({})); + let n = selected(&s); + assert!(matches!(n.kind, NodeKind::Path { live: None, .. }), "Object still expands the rectangle"); + assert!(matches!(n.appearance.fill_paint(), Paint::Pattern { .. }), "the fill is kept"); + // A pattern scaled by 1e-20 covers the square with more tile positions than can be counted. + let mut s = session(); + let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false); + run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [1e-20, 0, 0, 1e-20, 0, 0]})); + assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err()); + assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Pattern { .. }), "the fill is kept"); +} + +#[test] +fn type_filled_with_a_pattern_expands_to_outlines_holding_the_tiles() { + let mut s = session(); + pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false); + run(&mut s, "edit.clear", json!({})); + let t = run(&mut s, "text.create", json!({"x": 50, "y": 100, "text": "Hi", "size": 48}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [t]})); + run(&mut s, "paint.setFill", json!({"swatch": "Dots"})); + assert_eq!(run(&mut s, "object.expand.info", json!({}))["fill"], json!(true)); + run(&mut s, "object.expand", json!({})); + let (mut clips, mut patterns) = (0, 0); + for id in s.doc().unwrap().selection.objects.clone() { + node(&s, id).walk(&mut |n| { + clips += usize::from(matches!(n.kind, NodeKind::Group { clip: true, .. })); + patterns += n.appearance.items.iter().filter(|i| matches!(i.paint(), Paint::Pattern { .. })).count(); + }); + } + assert!(clips > 0 && patterns == 0, "{clips} clip groups, {patterns} pattern paints left"); +} + +#[test] +fn an_expanded_pattern_fill_keeps_its_transparency_and_the_stroke_on_top() { + let mut s = session(); + pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), true); + // [Fill (pattern), Stroke, Fill (green, on top)], the pattern fill at 50% in Multiply. + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "paint.setFill", json!({"item": 2, "color": "#00ff00"})); + run(&mut s, "transparency.set", json!({"item": 2, "opacity": 30})); + run(&mut s, "transparency.set", json!({"item": 0, "opacity": 50, "blend": "multiply"})); + let before = render(&s); + run(&mut s, "object.expand", json!({"stroke": false})); + let g = selected(&s); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("{:?}", g.kind) }; + assert_eq!(children.len(), 2); + assert_eq!((children[0].opacity, children[0].blend), (0.5, BlendMode::Multiply)); + assert_eq!(clip_parts(&children[0]).1.len(), 12); + // The stroke and the top fill stay on a copy above, in paint order. + let items = &children[1].appearance.items; + assert_eq!(items.len(), 2); + assert_eq!(children[1].appearance.stroke_paint().color().unwrap().to_hex(), "#0000ff"); + assert!(matches!(&items[1], AppearanceItem::Fill(f) if f.paint.color().unwrap().to_hex() == "#00ff00")); + assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same"); + // Stroke on as well: the stroke becomes a filled outline over the tiles. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "object.expand", json!({})); + let g = selected(&s); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("{:?}", g.kind) }; + assert_eq!(clip_parts(&children[0]).1.len(), 12); + assert_eq!(fill_color(&children[1]).to_hex(), "#0000ff", "the stroke became a filled outline"); +} + +#[test] +fn a_gradient_that_cannot_be_expanded_leaves_the_object_as_it_was() { + // A freeform gradient on a path of one anchor has no outline to shape a mesh to. + let mut s = session(); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 50, "y": 50}]}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}})); + let before = node(&s, id); + assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "nothing to expand"); + assert_eq!(node(&s, id), before); +} + #[test] fn create_gradient_mesh_takes_its_colours_from_the_gradient() { let mut s = session(); @@ -258,7 +832,7 @@ fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() { fn defaults_match_the_dialog_and_info_tells_what_applies() { let mut s = session(); gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true); - assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": true})); + assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": true, "gradient": true})); let r = run(&mut s, "object.expand", json!({})); assert_eq!(r["ids"].as_array().unwrap().len(), 1); // Object, Fill and Stroke on; 255 objects: the live rectangle, its stroke and its gradient. @@ -269,7 +843,7 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() { assert_eq!(strips.len(), 255); assert!(children[1].appearance.stroke_paint().is_none(), "outlined stroke"); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand"); - assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": false, "fill": false, "stroke": false})); + assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": false, "fill": false, "stroke": false, "gradient": false})); assert!(s.execute("object.expand", &json!({})).is_err(), "nothing left to expand"); // Bad options are refused before anything changes. run(&mut s, "edit.undo", json!({})); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index 6249df2e1..170f31acd 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -162,3 +162,21 @@ fn brushed_strokes_outline_to_their_brush_art() { assert!(near(n.opacity as f64, 0.5, 1e-6), "the stroke opacity stays on the art"); assert!(!doc.layers[0].children().unwrap().iter().any(|c| vectorcraft_brush::has_brush(c))); } + +#[test] +fn a_brushed_stroke_keeps_its_effects_on_its_brush_art() { + let mut s = session(); + line(&mut s, json!({"weight": 2})); + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": 1})).unwrap(); + s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 1})).unwrap(); + let n = outline(&mut s); + let count = |id: &str| { + let mut k = 0; + n.walk(&mut |m| k += m.appearance.effects.iter().filter(|e| e.id == id).count()); + k + }; + assert_eq!(count("stylize.dropShadow"), 1, "the drop shadow is on the brush art: {n:?}"); + // A geometry effect changes the path the brush follows; it is not applied to the brush art. + assert_eq!(count("path.offsetPath"), 0); +} diff --git a/crates/engine/src/tests_strokegradient.rs b/crates/engine/src/tests_strokegradient.rs index eb9b03355..c9c0a4e1d 100644 --- a/crates/engine/src/tests_strokegradient.rs +++ b/crates/engine/src/tests_strokegradient.rs @@ -123,6 +123,18 @@ fn outline_stroke_and_expand_keep_the_picture() { } } +#[test] +fn outline_stroke_keeps_the_effect_of_a_stroke_with_a_gradient_along_it() { + let (mut s, _) = setup("along"); + let stroke = doc(&s).node(s.doc().unwrap().selection.objects[0]).unwrap().appearance.items.len() - 1; + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})); + let ids = run(&mut s, "object.path.outlineStroke", json!({}))["ids"].clone(); + let n = doc(&s).node(NodeId(ids[0].as_u64().unwrap())).unwrap().clone(); + let mut shadowed = false; + n.walk(&mut |c| shadowed |= matches!(c.kind, NodeKind::Group { clip: true, .. }) && !c.appearance.effects.is_empty()); + assert!(shadowed, "the stroke's drop shadow is on its outline's clip group"); +} + #[test] fn svg_and_pdf_write_the_same_gradient_as_slices_with_a_warning() { for mode in ["along", "across"] { diff --git a/crates/ui-egui/src/dialogs/expand.rs b/crates/ui-egui/src/dialogs/expand.rs index dba30e3aa..85be7ab1b 100644 --- a/crates/ui-egui/src/dialogs/expand.rs +++ b/crates/ui-egui/src/dialogs/expand.rs @@ -1,10 +1,11 @@ -//! Object → Expand: what to expand (Object: live shapes, type and effects; Fill: gradient fills; +//! Object → Expand: what to expand (Object: live shapes, type and effects; Fill: gradient and pattern fills; //! Stroke: strokes into filled outlines) and what gradient fills become (a gradient mesh, or a //! number of objects). Options the selection has nothing for are disabled. OK runs //! `object.expand` as one undo step. //! //! Fields: `object`, `fill`, `stroke`, `gradient` (`objects` | `mesh`), `steps` (1..1000), and -//! `__object`, `__fill`, `__stroke` (that option has something to expand: `object.expand.info`). +//! `__object`, `__fill`, `__stroke` (that option has something to expand: `object.expand.info`) and +//! `__gradient` (Fill has a gradient fill to expand). use serde_json::{Value, json}; use vectorcraft_engine::cmd::expand::{DEFAULT_STEPS, MAX_STEPS}; @@ -30,6 +31,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { for (key, _) in OPTIONS { fields[format!("__{key}")] = info[key].clone(); } + fields["__gradient"] = info["gradient"].clone(); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) } @@ -50,8 +52,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } ui.add_space(10.0); widgets::subheader(ui, tl!("Expand Gradient To")); - // Only gradient fills that Fill expands care. - let enabled = d.bool("__fill") && d.bool("fill"); + let enabled = gradient_to_enabled(d); let objects = d.str("gradient") != "mesh"; ui.horizontal(|ui| { ui.add_space(12.0); @@ -74,6 +75,11 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// Does Expand Gradient To apply? Only gradient fills that Fill expands care. +fn gradient_to_enabled(d: &Dialog) -> bool { + d.bool("__gradient") && d.bool("fill") +} + /// `object.expand` parameters from the fields. fn params(d: &Dialog) -> Value { json!({ @@ -127,6 +133,26 @@ mod tests { assert!(matches!(children[1].kind, NodeKind::Mesh(_))); } + #[test] + fn expand_gradient_to_is_disabled_for_a_pattern_fill_alone() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let dot = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + app.run("object.pattern.make", json!({"ids": [dot], "name": "Dots", "width": 20, "height": 20, "edit": false})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Dots"})).unwrap(); + app.run("ui.expandDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.bool("__fill"), "Fill expands the pattern fill"); + assert!(!gradient_to_enabled(d), "no gradient fill for Expand Gradient To"); + frame(&mut app); + // A gradient fill enables it. + app.ui.dialog = None; + app.run("paint.setFill", json!({"gradient": {"kind": "linear"}})).unwrap(); + app.run("ui.expandDialog", json!({})).unwrap(); + assert!(gradient_to_enabled(app.ui.dialog.as_ref().unwrap())); + } + #[test] fn the_command_palette_opens_it_and_ui_expand_dialog_too() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0bd210bcc..ebef5e03f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -401,7 +401,8 @@ changed since); its preview greys the art and paints the highlighted objects or Expand: Object → Expand… (`ui.expandDialog`, or `object.expand` without params from the menus, palette and shortcuts) opens the `expand` dialog for the selection. Fields: `object`, `fill`, `stroke` (all on; `__object`, `__fill`, `__stroke` say which the selection has something for, as `object.expand.info` does, and the others are disabled), -`gradient` (`objects` or `mesh`) and `steps` (1–1000, default 255). `ui.dialog.confirm` runs `object.expand` with them as +`gradient` (`objects` or `mesh`) and `steps` (1–1000, default 255), which are enabled when `fill` is on and +`__gradient` says the selection has a gradient fill (`object.expand.info`'s `gradient`). `ui.dialog.confirm` runs `object.expand` with them as one undo step. Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its controls run `attributes.set`, diff --git a/docs/mcp.md b/docs/mcp.md index 9c7f2b045..1ab2ef5cc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1687,9 +1687,27 @@ outlines strokes into filled paths; `fill` (on) expands gradient fills: with `gr no seams show; concentric ellipses for a radial one, largest first; just the bands when stops are translucent), with `gradient: "mesh"` into a gradient mesh that paints the gradient (columns or rings where its colour changes, sharp at coincident stops), each inside a clip group shaped like the object. A freeform gradient becomes a mesh shaped like the -object either way. Expanded colours keep the colour model their stops share. With `fill: false` gradient fills stay -live. `object.expand.info` answers which options have something to expand in the selection (`{object, fill, -stroke}`), and `object.mesh.create` on a gradient-filled object colours the mesh points as the gradient paints them. +object either way. Expanded colours keep the colour model their stops share. `fill` also expands pattern fills: each +becomes the pattern's tiles that reach the object's box, placed by the pattern's transform, in a clip group shaped like +the object that isolates their blending, with the fill's opacity, blend mode and effects; the object's other fills +and strokes stay on copies of it in paint order. The tiles' stroke weights and effect distances scale with the +pattern, and opacity masks in the tile art, unlinked ones included, move with each tile. When the pattern is rotated, +reflected, stretched or sheared, or scaled while its art has geometry effects (Roughen's detail is per inch), the +strokes (brushed ones included) and geometry effects of the tiles' paths become filled art and their live shapes +become plain paths, in their opacity masks too. A path in the tile art with a Transform effect set to Random, or with +a brush that varies at random (a calligraphic or scatter brush with Random variation, or a bristle brush), becomes +filled art first, so every tile shows the one result the canvas draws. A pattern fill stays as it is when it lays no +tiles there (as when the box would take more than 250,000 tile positions), when a group or type in its art has a +Transform effect set to Random, when the pattern is scaled, rotated, reflected, stretched or sheared and its art holds +a symbol instance (the canvas scales the instance's strokes with the tile), and when the pattern is rotated, +reflected, stretched or sheared, or scaled while its art has geometry effects, and its art has raster or 3D effects, +or a geometry effect on a group or type, which the canvas draws turned or scaled with each tile. The geometry effects of a gradient or pattern fill (such as Offset Path) +and those of the object shape the outline of the clip group, the gradient fits that outline and the tiles reach its +box, as the canvas paints the fill; the expanded art does not keep them. With `fill: false` gradient and pattern fills +stay live. `object.expand.info` answers which options have something to expand in the selection +(`{object, fill, stroke, gradient}`; `gradient` says whether Fill has a gradient fill, which Expand Gradient To +applies to), and `object.mesh.create` on a gradient-filled object colours the mesh points as the +gradient paints them. ```json {"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ffbfe670d..ce08b5b50 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -692,6 +692,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. +- **Pattern fills in Expand:** Object › Expand with Fill turns a pattern fill into the pattern's tiles in a clip group shaped like the object, with the fill's opacity and blend mode, and the object's other fills and strokes stay above or below it in paint order, in one undo step (`object.expand`). + - **Type with transparency or effects:** type at less than 100% opacity, in a blend mode other than Normal, or with raster effects such as a drop shadow keeps its glyph outlines between frames, as opaque type does, and is laid out again when it is edited or the fonts change. At 2880 × 1800 on 8 threads, a page of 1,000 type objects takes 13 ms a frame instead of 23 ms at 80% opacity, 31 ms instead of 41 ms in Multiply and 16 ms instead of 26 ms with a drop shadow, with the same pixels. - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it.