From 705976fd96392431fce9c1b0171c5abb30172562 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:20:00 +0700 Subject: [PATCH] M3.84: Object > Expand dialog; gradient fills expand to a mesh or N objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object > Expand… opens a purpose-built dialog (Object, Fill and Stroke checkboxes, disabled when the selection has nothing for them; Expand Gradient To: Gradient Mesh or Specify N Objects, default 255). Every option now does something: - Object: type to outlines, live shapes to paths, effects baked. - Stroke: strokes outlined into filled paths. - Fill: linear/radial gradient fills become N solid rectangles or concentric ellipses, or a gradient mesh with lines where the colour changes, inside a clip group shaped like the object; freeform gradients become a mesh shaped like the object. Colours keep their stops' colour model. With Fill off gradient fills stay live. object.expand {} defaults match the dialog (stroke now defaults on); new query object.expand.info and UI command ui.expandDialog. Create Gradient Mesh on a gradient-filled object takes the colours the gradient paints at each point (GradientMesh::for_path_with, from_grid, highlight). Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 7 +- crates/doc/src/live.rs | 86 ++++-- crates/engine/src/cmd/expand.rs | 408 +++++++++++++++++++++++++++ crates/engine/src/cmd/live.rs | 18 +- crates/engine/src/cmd/menucmds.rs | 74 +---- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/object.rs | 6 +- crates/engine/src/cmd/pathops.rs | 49 ++-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_menucmds.rs | 3 +- crates/engine/src/tests_objexpand.rs | 273 ++++++++++++++++++ crates/ui-egui/src/dialogs/expand.rs | 146 ++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/menus.rs | 17 +- docs/control-protocol.md | 6 + docs/mcp.md | 18 ++ 16 files changed, 990 insertions(+), 127 deletions(-) create mode 100644 crates/engine/src/cmd/expand.rs create mode 100644 crates/engine/src/tests_objexpand.rs create mode 100644 crates/ui-egui/src/dialogs/expand.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index d7c96ff3..8e2ee33b 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -87,6 +87,11 @@ impl Default for Gradient { impl Gradient { /// Colour and opacity at `t` (honours midpoints). pub fn sample(&self, t: f32) -> (Color, f32) { + self.sample_with(t, Color::lerp) + } + /// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour + /// model rather than display RGB). + pub fn sample_with(&self, t: f32, mix: impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) { let stops = &self.stops; if stops.is_empty() { return (Color::BLACK, 1.0); @@ -102,7 +107,7 @@ impl Gradient { // Map through the midpoint: u=mid → 0.5. let m = a.midpoint.clamp(0.01, 0.99); let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) }; - return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v); + return (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v); } } let l = stops.last().unwrap(); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index ea8eaa76..623c57d4 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -812,6 +812,12 @@ impl Coons { // Gradient meshes // ===================================================================================== +/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps. +fn even_steps(n: u32) -> Vec { + let n = n.clamp(1, 200); + (0..=n).map(|i| i as f64 / n as f64).collect() +} + impl GradientMesh { pub fn idx(&self, r: usize, c: usize) -> usize { r * (self.cols as usize + 1) + c @@ -822,43 +828,73 @@ impl GradientMesh { /// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives. pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self { - let (rows, cols) = (rows.clamp(1, 200), cols.clamp(1, 200)); - let (du, dv) = (1.0 / cols as f64, 1.0 / rows as f64); + let (us, vs) = (even_steps(cols), even_steps(rows)); + Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0)) + } + + /// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs` + /// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour + /// change), with handles from its partial derivatives. `color(row, col, point)` gives each + /// point its colour and opacity. + pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self { + let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1)); let eps = 1e-3; - let mut points = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize); - for r in 0..=rows { - for c in 0..=cols { - let (u, v) = (c as f64 * du, r as f64 * dv); + let mut points = Vec::with_capacity(us.len() * vs.len()); + for (r, &v) in vs.iter().enumerate() { + for (c, &u) in us.iter().enumerate() { let p = s(u, v); let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0)); let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0)); - let hu = d_u * (du / 3.0); - let hv = d_v * (dv / 3.0); - // Outward handles on the border are unused: keep them at the point. - let z = Vec2::ZERO; - let handles = - [if c < cols { hu } else { z }, if c > 0 { -hu } else { z }, if r < rows { hv } else { z }, if r > 0 { -hv } else { z }]; - points.push(MeshPoint { p, color: color(u, v), opacity: 1.0, handles }); + // A third of the way to the neighbouring line along the tangent; outward handles on + // the border are unused, so they stay at the point. + let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0)); + let handles = [ + h(d_u, u, us.get(c + 1)), + h(d_u, u, c.checked_sub(1).map(|i| &us[i])), + h(d_v, v, vs.get(r + 1)), + h(d_v, v, r.checked_sub(1).map(|i| &vs[i])), + ]; + let (color, opacity) = color(r, c, p); + points.push(MeshPoint { p, color, opacity, handles }); } } - Self { rows, cols, points } + Self { rows: rows as u32, cols: cols as u32, points } } /// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`. pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option { + let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?; + m.highlight(appearance, highlight); + Some(m) + } + + /// Create Gradient Mesh for a path: the outline split at its corners, each point taking the + /// colour and opacity `color_at` gives at its position (a gradient fill's, say). + pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option { let coons = Coons::from_path(path)?; + Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p))) + } + + /// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to + /// the edges, by at most `highlight` %. + pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) { let h = (highlight / 100.0).clamp(0.0, 1.0) as f32; - let color = |u: f64, v: f64| -> Color { - let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge - let w = match appearance { - MeshAppearance::Flat => 0.0, - MeshAppearance::ToCenter => (1.0 - d) * h, - MeshAppearance::ToEdge => d * h, - }; - if w > 0.0 { lerp_color(&base, &Color::WHITE, w) } else { base } - }; - let s = |u: f64, v: f64| coons.eval(u, v); - Some(Self::from_surface(rows, cols, &s, &color)) + let (rows, cols) = (self.rows as usize, self.cols as usize); + for r in 0..=rows { + for c in 0..=cols { + let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge + let w = match appearance { + MeshAppearance::Flat => 0.0, + MeshAppearance::ToCenter => (1.0 - d) * h, + MeshAppearance::ToEdge => d * h, + }; + if w > 0.0 { + let i = self.idx(r, c); + self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w); + } + } + } } /// Horizontal edge from (r, c) to (r, c+1). diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs new file mode 100644 index 00000000..f6d0622e --- /dev/null +++ b/crates/engine/src/cmd/expand.rs @@ -0,0 +1,408 @@ +//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills +//! (Fill) into plain art in one undo step. 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. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::cms::Model; +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_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::*; + +const C: &str = "object.expand"; +/// Expand Gradient To → Specify … Objects: the default and the most. +pub const DEFAULT_STEPS: u32 = 255; +pub const MAX_STEPS: u32 = 1000; +/// Rows and columns of the mesh a freeform gradient expands to. +const FREEFORM_MESH: u32 = 8; +/// Columns (around the centre) of the mesh a radial gradient expands to. +const RADIAL_MESH_COLS: usize = 8; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.expand", + "Expand…", + ["Object"], + None, + "{object?: true (type → outlines, live shapes → paths, effects baked), 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}", + has_selection, + expand + ), + cmd!( + query "object.expand.info", + "Expand Options", + [], + None, + "{} what each Expand option would change in the selection → {object, fill, stroke} (true: something to expand)", + has_selection, + |s, _| { + let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke})) + } + ), + ] +} + +/// Expand Gradient To. +#[derive(Clone, Copy, Debug, PartialEq)] +enum GradientTo { + Mesh, + Objects(u32), +} + +impl GradientTo { + fn parse(p: &Value) -> Result { + let steps = f64_or(p, "steps", DEFAULT_STEPS as f64).round(); + if !(1.0..=MAX_STEPS as f64).contains(&steps) { + return Err(bad(C, format!("steps must be 1..{MAX_STEPS}"))); + } + match str_param(p, "gradient").unwrap_or("objects") { + "objects" => Ok(Self::Objects(steps as u32)), + "mesh" => Ok(Self::Mesh), + _ => Err(bad(C, "gradient must be \"objects\" or \"mesh\"")), + } + } +} + +/// What the selection holds for each Expand option. +#[derive(Default)] +struct Expandable { + text: bool, + live: bool, + effects: bool, + fill: bool, + stroke: bool, +} + +impl Expandable { + fn of(d: &Document, roots: &[NodeId]) -> Self { + let mut e = Self::default(); + for n in roots.iter().filter_map(|r| d.node(*r)) { + n.walk(&mut |c| { + let shape = matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + e.text |= matches!(c.kind, NodeKind::Text(_)); + e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. }); + e.effects |= !c.appearance.effects.is_empty(); + e.stroke |= shape && !c.appearance.stroke_paint().is_none(); + e.fill |= 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 + } + + fn object(&self) -> bool { + self.text || self.live || self.effects + } +} + +/// The gradient of a visible gradient fill. +fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> { + match i { + AppearanceItem::Fill(f) if f.visible => match &f.paint { + Paint::Gradient(g) => Some((f, g)), + _ => None, + }, + _ => None, + } +} + +fn expand(s: &mut Session, p: &Value) -> Result { + let object = bool_or(p, "object", true); + let fill = bool_or(p, "fill", true); + let stroke = bool_or(p, "stroke", true); + let to = GradientTo::parse(p)?; + let from = s.doc()?.history.undo.len(); + let rev0 = s.doc()?.revision; + let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + if object && e.effects { + let _ = run_raw(s, "effect.expandAppearance", &json!({})); + } + if object && e.live { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| { + for r in &roots { + if let Some(n) = d.node_mut(*r) { + drop_live(n); + } + } + Ok(()) + })?; + } + if object && e.text { + let _ = run_raw(s, "type.createOutlines", &json!({})); + } + if stroke && e.stroke { + let _ = run_raw(s, "object.path.outlineStroke", &json!({})); + } + if fill { + let _ = expand_gradient_fills(s, to); + } + if s.doc()?.revision == rev0 { + return Err(EngineError::Other("Expand: nothing to expand".into())); + } + squash(s, from, "Expand"); + Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::>() })) +} + +fn drop_live(n: &mut Node) { + if let NodeKind::Path { live, .. } = &mut n.kind { + *live = None; + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + drop_live(Arc::make_mut(c)); + } + } +} + +/// Replace every gradient fill of the selected paths by art (see [`expand_fills`]). +fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> 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, |d, l| expand_fills(d, l, to))?; + ids.push(id); + changed += n; + } + if changed == 0 { + return Err(EngineError::Other("Expand: no gradient fills".into())); + } + sel.set(ids); + Ok(()) + }) +} + +/// 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. +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 (path, _) = node_path(l)?; + let bounds = path.bounds()?; + let mut parts = vec![]; + let mut rest = vec![]; + 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 { + 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); + } + if !rest.is_empty() { + parts.push(styled_copy(d, l, rest)); + } + assemble(d, l, parts) +} + +/// Gradient fill `f` (gradient `g`) of path `l` (outline `path`, box `bounds`) as art, with the +/// fill's opacity, blend mode and 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))?; + vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))] + } else { + let frame = Frame::new(g, bounds); + match to { + GradientTo::Mesh => vec![Node::new(d.alloc_id(), NodeKind::Mesh(frame.mesh(&g.gradient)))], + GradientTo::Objects(n) => frame + .pieces(&g.gradient, n as usize) + .into_iter() + .map(|(shape, (color, opacity))| { + let fill = FillLayer { opacity, overprint: f.overprint, ..FillLayer::new(Paint::solid(color)) }; + Node::path(d.alloc_id(), shape, Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }) + }) + .collect(), + } + }; + // A freeform mesh takes the object's outline: only holes need the object to clip it. + let art = if g.gradient.kind == GradientKind::Freeform && path.subpaths.len() == 1 { + content.into_iter().next()? + } else { + let mut clip = styled_copy(d, l, vec![]); + as_clipping_path(&mut clip).ok()?; + let children = std::iter::once(clip).chain(content).map(Arc::new).collect(); + 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(); + Some(art) +} + +/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is +/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the +/// colour model their stops (or freeform points) share; display RGB where models differ. +pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box (Color, f32) + '_> { + if g.gradient.kind == GradientKind::Freeform { + let b = bounds.abs(); + let f = g.freeform_on(b); + let model = shared_model(f.points.iter().map(|p| p.color)); + let field = f.field(spread_scale(b)); + return Box::new(move |p| { + let ([r, g, b], a) = field.sample(p); + let c = Color::rgb(r, g, b); + (model.map_or(c, |m| c.in_model(m)), a) + }); + } + let geom = g.resolve(bounds); + Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color)) +} + +/// The colour model all of `colors` share. +fn shared_model(mut colors: impl Iterator) -> Option { + let m = colors.next()?.model(); + colors.all(|c| c.model() == m).then_some(m) +} + +/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every +/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint +/// off the centre, as (parameter, colour, opacity) in order, with more between them where they +/// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the +/// canvas draws it. +fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> { + let at = |t: f64| (t, g.sample_with(t as f32, lerp_color)); + let mut v = vec![at(t0)]; + for (i, s) in g.stops.iter().enumerate() { + let o = s.offset as f64; + if o > t0 && o < t1 { + v.push((o, (s.color, s.opacity))); + } + if let Some(n) = g.stops.get(i + 1) + && (s.midpoint - 0.5).abs() > 1e-3 + { + let m = (s.offset + (n.offset - s.offset) * s.midpoint) as f64; + if m > t0 && m < t1 { + v.push(at(m)); + } + } + } + v.push(at(t1)); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out = Vec::with_capacity(v.len()); + for w in v.windows(2) { + out.push(w[0]); + let (a, b) = (w[0].0, w[1].0); + let n = ((b - a) / max_gap).ceil() as usize; + out.extend((1..n).map(|k| at(a + (b - a) * k as f64 / n as f64))); + } + out.extend(v.last().copied()); + out +} + +/// A linear or radial gradient's geometry over an object's box: its start, the vector to its +/// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that +/// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial: +/// `0..t1`). +struct Frame { + radial: bool, + start: Point, + along: Vec2, + across: Vec2, + t0: f64, + t1: f64, + s0: f64, + s1: f64, +} + +impl Frame { + fn new(g: &GradientPaint, bounds: Rect) -> Self { + let geom = g.resolve(bounds); + let radial = g.gradient.kind == GradientKind::Radial; + // A vector of no length paints as one pointing right (as the canvas does). + let along = if geom.start.distance(geom.end) < 1e-9 { Vec2::new(1.0, 0.0) } else { geom.end - geom.start }; + let normal = Vec2::new(-along.y, along.x) / along.hypot(); + let across = if radial { normal * along.hypot() * geom.aspect } else { normal }; + let corners = [bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)]; + let range = |f: &dyn Fn(Point) -> f64| corners.iter().map(|c| f(*c)).fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(x), hi.max(x))); + let (mut t0, mut t1) = range(&|c| (c - geom.start).dot(along) / along.hypot2()); + let (s0, s1) = range(&|c| (c - geom.start).dot(normal)); + if radial { + let geom = vectorcraft_color::GradientGeom { end: geom.start + along, ..geom }; + (t0, t1) = (0.0, range(&|c| geom.param_at(GradientKind::Radial, c)).1); + } + // Keep a sliver of span so the art maps back invertibly. + t1 = t1.max(t0 + 1e-6); + Self { radial, start: geom.start, along, across, t0, t1, s0, s1 } + } + + /// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t` + /// and angle `s` (radians). + fn at(&self, t: f64, s: f64) -> Point { + if self.radial { self.start + (self.along * s.cos() + self.across * s.sin()) * t } else { self.start + self.along * t + self.across * s } + } + + /// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour + /// changes, so it paints the gradient exactly. + fn mesh(&self, g: &Gradient) -> GradientMesh { + // Rings as close as the columns around them, so the mesh draws smoothly. + let gap = if self.radial { (self.t1 - self.t0) / RADIAL_MESH_COLS as f64 } else { f64::INFINITY }; + let br = breaks(g, self.t0, self.t1, gap); + let ts: Vec = br.iter().map(|(t, _)| (t - self.t0) / (self.t1 - self.t0)).collect(); + let t = |x: f64| self.t0 + x * (self.t1 - self.t0); + if self.radial { + let angles: Vec = (0..=RADIAL_MESH_COLS).map(|i| i as f64 / RADIAL_MESH_COLS as f64).collect(); + GradientMesh::from_grid(&angles, &ts, &|u, v| self.at(t(v), u * std::f64::consts::TAU), &|r, _, _| br[r].1) + } else { + GradientMesh::from_grid(&ts, &[0.0, 1.0], &|u, v| self.at(t(u), self.s0 + v * (self.s1 - self.s0)), &|_, c, _| br[c].1) + } + } + + /// `n` solid shapes covering the box in paint order, each coloured as the gradient in the + /// middle of its band: for a linear gradient, rectangles from each band's start to the far + /// end, for a radial one, concentric ellipses (largest first). Each paints over the ones + /// below except its band, so no seam shows between bands. With translucent stops each shape + /// is just its band, so nothing doubles up. + fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { + let step = (self.t1 - self.t0) / n as f64; + let color = |k: usize| g.sample_with((self.t0 + (k as f64 + 0.5) * step) as f32, lerp_color); + let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); + if self.radial { + let ellipse = |t: f64| { + let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, self.start.x, self.start.y]); + shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m) + }; + return (0..n) + .rev() + .map(|k| { + let mut shape = ellipse((k + 1) as f64 * step); + if !opaque && k > 0 { + let mut hole = ellipse(k as f64 * step).subpaths.remove(0); + hole.reverse(); + shape.subpaths.push(hole); + } + (shape, color(k)) + }) + .collect(); + } + (0..n) + .map(|k| { + let a = self.t0 + k as f64 * step; + let b = if opaque { self.t1 } else { a + step }; + let pts = [self.at(a, self.s0), self.at(b, self.s0), self.at(b, self.s1), self.at(a, self.s1)]; + (PathData::single(SubPath::polyline(&pts, true)), color(k)) + }) + .collect() + } +} diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 42ac9542..9a44adf2 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -14,6 +14,7 @@ use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Affine, PathData, Point}; use super::edit::selected_roots; +use super::expand::gradient_sampler; use super::*; pub fn specs() -> Vec { @@ -161,7 +162,7 @@ pub fn specs() -> Vec { "Create Gradient Mesh…", ["Object"], None, - "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y]} convert filled paths into gradient meshes (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}", + "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y]} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}", has_selection, mesh_create ), @@ -723,12 +724,17 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result { for id in &roots { let Some(n) = d.node(*id).cloned() else { continue }; let Some(path) = outline_of(&n) else { continue }; - let base = match n.appearance.fill_paint() { - Paint::Solid { color, .. } => color, - Paint::Gradient(g) => g.gradient.sample(0.5).0, - _ => Color::WHITE, + let m = match n.appearance.fill_paint() { + // The points take the colours the gradient paints there. + Paint::Gradient(g) => { + path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| { + m.highlight(app, highlight); + m + }) + } + p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight), }; - let Some(mut m) = GradientMesh::for_path(&path, rows, cols, base, app, highlight) else { continue }; + let Some(mut m) = m else { continue }; if let Some(q) = at { index = m.add_lines_at(q); } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 0fcd2de8..09e1e0eb 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -1,5 +1,5 @@ //! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset -//! Bounding Box, Expand, Rasterize, Crop Image, Create Trim Marks, Convert to Shape and +//! Bounding Box, Rasterize, Crop Image, Create Trim Marks, Convert to Shape and //! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.) use std::sync::Arc; @@ -83,15 +83,6 @@ pub fn specs() -> Vec { has_selection, |_, _| Ok(json!({ "changed": 0 })) ), - cmd!( - "object.expand", - "Expand…", - ["Object"], - None, - "{object?: true (text → outlines, live shapes → paths, effects baked), fill?: true, stroke?: false (strokes → filled outlines)} one undo step → {ids}", - has_selection, - expand - ), cmd!( "object.rasterize", "Rasterize…", @@ -299,69 +290,6 @@ fn transform_each(s: &mut Session, p: &Value) -> Result { Ok(ids_json(&ids)) } -// ---------- Expand ---------- - -fn expand(s: &mut Session, p: &Value) -> Result { - let object = bool_or(p, "object", true); - let fill = bool_or(p, "fill", true); - let stroke = bool_or(p, "stroke", false); - let from = s.doc()?.history.undo.len(); - let rev0 = s.doc()?.revision; - let roots = selected_roots(s)?; - let d = &s.doc()?.doc; - let (mut has_text, mut has_fx, mut has_live, mut has_stroke) = (false, false, false, false); - for r in &roots { - if let Some(n) = d.node(*r) { - n.walk(&mut |c| { - has_text |= matches!(c.kind, NodeKind::Text(_)); - has_live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. }); - has_fx |= !c.appearance.effects.is_empty(); - has_stroke |= matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !c.appearance.stroke_paint().is_none(); - }); - } - } - if object && has_fx { - let _ = run_raw(s, "effect.expandAppearance", &json!({})); - } - if object && has_live { - let roots = selected_roots(s)?; - s.edit("Expand", |d, _| { - for r in &roots { - if let Some(n) = d.node_mut(*r) { - drop_live(n); - } - } - Ok(()) - })?; - } - if object && has_text { - let _ = run_raw(s, "type.createOutlines", &json!({})); - } - if stroke && has_stroke { - let _ = run_raw(s, "object.path.outlineStroke", &json!({})); - } - if !fill { - // Fill unchecked: strokes only survive (Illustrator expands just the selected attributes). - } - if s.doc()?.revision == rev0 { - return Err(EngineError::Other("Expand: nothing to expand".into())); - } - squash(s, from, "Expand"); - let ids = s.doc()?.selection.objects.clone(); - Ok(ids_json(&ids)) -} - -fn drop_live(n: &mut Node) { - if let NodeKind::Path { live, .. } = &mut n.kind { - *live = None; - } - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - drop_live(Arc::make_mut(c)); - } - } -} - // ---------- Rasterize / Crop ---------- pub(crate) const MAX_PIXELS: f64 = 64.0e6; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 971206a1..5878143e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -13,6 +13,7 @@ mod docmenu; mod draw2; mod edit; mod effectcmd; +pub mod expand; pub mod fileio; pub mod flatten; mod fonts; @@ -202,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(freeform::specs()); v.extend(flatten::specs()); v.extend(stylelib::specs()); + v.extend(expand::specs()); v }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index fc45480b..9e2b7237 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -536,7 +536,11 @@ fn compound_release(s: &mut Session, _: &Value) -> Result { /// Make `top` (a path, compound path or text object) the clipping path: it loses its paint (it /// stays unpainted after Release). pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> { - let c = d.node_mut(top).ok_or(EngineError::NoNode(top))?; + as_clipping_path(d.node_mut(top).ok_or(EngineError::NoNode(top))?) +} + +/// Turn path, compound path or text `c` into a clipping path (its paint goes). +pub(super) fn as_clipping_path(c: &mut Node) -> Result<()> { if !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into())); } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index f617574c..e7b920d3 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -445,7 +445,7 @@ fn outlinable(st: &StrokeLayer) -> bool { /// `n` with `opacity` and `blend` applied: set on it when it has none of its own, else on a /// group around it. -fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node { +pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node { if opacity >= 1.0 && blend == BlendMode::Normal { return n; } @@ -503,24 +503,12 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br let (path, rule) = node_path(l)?; let mut parts: Vec = vec![]; let mut fills: Vec = vec![]; - let fill_node = |d: &mut Document, items: Vec| { - let mut f = d.reid(l); - f.appearance = Appearance { items, ..Default::default() }; - f.opacity = 1.0; - f.blend = BlendMode::Normal; - f.name = None; - f.mask = None; - if let NodeKind::Path { live, .. } = &mut f.kind { - *live = None; - } - f - }; for item in &l.appearance.items { match item { AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()), AppearanceItem::Stroke(st) if outlinable(st) => { if !fills.is_empty() { - parts.push(fill_node(d, std::mem::take(&mut fills))); + parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } parts.extend(outlined_stroke(d, brushes, &path, rule, st)); } @@ -528,8 +516,28 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br } } if !fills.is_empty() { - parts.push(fill_node(d, fills)); + parts.push(styled_copy(d, l, fills)); } + assemble(d, l, parts) +} + +/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`. +pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec) -> Node { + let mut f = d.reid(l); + f.appearance = Appearance { items, ..Default::default() }; + f.opacity = 1.0; + f.blend = BlendMode::Normal; + f.name = None; + f.mask = None; + if let NodeKind::Path { live, .. } = &mut f.kind { + *live = None; + } + 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. +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), @@ -545,15 +553,18 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br /// Outline the strokes of every path under `root` in place: → (the id now standing for `root`, /// how many paths changed). pub(crate) fn outline_strokes_under(d: &mut Document, brushes: &[vectorcraft_brush::Brush], root: NodeId) -> Result<(NodeId, usize)> { + replace_leaves(d, root, |d, l| if matches!(l.kind, NodeKind::Text(_)) { None } else { outline_strokes(d, brushes, l) }) +} + +/// Replace each leaf shape under `root` ([`leaves`]) by what `f` makes of it (`None` keeps it): +/// → (the id now standing for `root`, how many leaves changed). +pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&mut Document, &Node) -> Option) -> Result<(NodeId, usize)> { let Some(n) = d.node(root).cloned() else { return Ok((root, 0)) }; let mut lv = vec![]; leaves(&n, &mut lv); let (mut replacement, mut changed) = (root, 0); for l in lv { - if matches!(l.kind, NodeKind::Text(_)) { - continue; - } - let Some(new) = outline_strokes(d, brushes, &l) else { continue }; + let Some(new) = f(d, &l) else { continue }; let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?; let nid = d.insert(par, idx, new)?; d.remove(l.id)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index efef9dd6..cc4c7fca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -845,6 +845,8 @@ mod tests_maskview; #[cfg(test)] mod tests_menucmds; #[cfg(test)] +mod tests_objexpand; +#[cfg(test)] mod tests_opacitymask; #[cfg(test)] mod tests_outlinestroke; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 2a39fa8b..c4d21521 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -171,7 +171,8 @@ fn expand_live_shape_and_text_in_one_undo_step() { let t = text(&mut s, "Hi"); sel(&mut s, &[a, t]); let n = undo_len(&s); - s.execute("object.expand", &json!({})).unwrap(); + // Stroke off: the rectangle stays one path (its stroke isn't outlined). + s.execute("object.expand", &json!({"stroke": false})).unwrap(); assert!(matches!(node(&s, a).kind, NodeKind::Path { live: None, .. })); assert!(s.doc().unwrap().doc.node(t).is_none(), "text became outlines"); assert_eq!(undo_len(&s), n + 1); diff --git a/crates/engine/src/tests_objexpand.rs b/crates/engine/src/tests_objexpand.rs new file mode 100644 index 00000000..8ccf5f4d --- /dev/null +++ b/crates/engine/src/tests_objexpand.rs @@ -0,0 +1,273 @@ +//! 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. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Rect; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +/// A selected rectangle filled with `gradient` (paint.setFill's), with no stroke unless `stroke`. +fn gradient_rect(s: &mut Session, r: Rect, gradient: Value, stroke: bool) -> NodeId { + 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!({ "gradient": gradient })); + 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) +} + +/// The one selected object after a command. +fn selected(s: &Session) -> Node { + let sel = &s.doc().unwrap().selection.objects; + assert_eq!(sel.len(), 1, "one object selected"); + node(s, sel[0]) +} + +/// A clip group's clipping path and contents. +fn clip_parts(n: &Node) -> (&Node, Vec<&Node>) { + match &n.kind { + NodeKind::Group { clip: true, children } => (&children[0], children[1..].iter().map(|c| &**c).collect()), + k => panic!("expected a clip group, got {k:?}"), + } +} + +fn fill_color(n: &Node) -> Color { + n.appearance.fill_paint().color().unwrap_or_else(|| panic!("solid fill expected on {:?}", n.kind)) +} + +fn render(s: &Session) -> vectorcraft_render::Rendered { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts) +} + +/// The mean difference of the colour channels of two renders. +fn mean_diff(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> f64 { + let sum: u64 = a.pixels.iter().zip(b.pixels.iter()).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum as f64 / a.pixels.len() as f64 +} + +/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel. +fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize { + a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)).count() +} + +fn white_black() -> Value { + json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}) +} + +#[test] +fn eight_strips_match_the_gradient_samples() { + let mut s = session(); + gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), false); + let undo = s.doc().unwrap().history.undo.len(); + run(&mut s, "object.expand", json!({"steps": 8})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let g = selected(&s); + let (clip, strips) = clip_parts(&g); + assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. })); + assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(20.0, 20.0, 180.0, 120.0)); + assert_eq!(strips.len(), 8); + for (k, strip) in strips.iter().enumerate() { + // Band k starts at its eighth of the width (and runs on under the next bands, so no + // seams show) and shows the gradient at its middle. + let b = strip.geometric_bounds().unwrap(); + assert!((b.x0 - (20.0 + 20.0 * k as f64)).abs() < 1e-9, "strip {k} starts at {}", b.x0); + assert!((b.x1 - 180.0).abs() < 1e-9 && (b.y0 - 20.0).abs() < 1e-9 && (b.y1 - 120.0).abs() < 1e-9); + let gray = 1.0 - (k as f32 + 0.5) / 8.0; + let [r, g, bl] = fill_color(strip).to_rgb(); + assert!((r - gray).abs() < 1e-4 && (g - gray).abs() < 1e-4 && (bl - gray).abs() < 1e-4, "strip {k}: {r} {g} {bl} vs {gray}"); + } + run(&mut s, "edit.undo", json!({})); + assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Gradient(_))); +} + +#[test] +fn a_radial_gradient_becomes_concentric_ellipses() { + let mut s = session(); + gradient_rect(&mut s, Rect::new(50.0, 50.0, 150.0, 150.0), json!({"kind": "radial"}), false); + run(&mut s, "object.expand", json!({"steps": 4})); + let g = selected(&s); + let (_, rings) = clip_parts(&g); + assert_eq!(rings.len(), 4); + // Largest first, all centred on the gradient's centre; the largest reaches the box's corners. + let reach = 50.0 * 2f64.sqrt(); + for (i, ring) in rings.iter().enumerate() { + let b = ring.geometric_bounds().unwrap(); + assert!((b.center().x - 100.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6); + let r = reach * (4 - i) as f64 / 4.0; + assert!((b.width() / 2.0 - r).abs() < 1e-6 && (b.height() / 2.0 - r).abs() < 1e-6, "ring {i}: {b:?} vs radius {r}"); + } + // The centre disc shows the gradient at the middle of the first band (white → black). + let t = (reach / 8.0 / 50.0) as f32; + let [r, ..] = fill_color(rings[3]).to_rgb(); + assert!((r - (1.0 - t)).abs() < 1e-4, "{r} vs {}", 1.0 - t); + // Translucent stops: rings with holes instead of stacked discs. + let mut s = session(); + let gr = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000", "opacity": 0.5}, {"offset": 1, "color": "#0000ff"}]}); + gradient_rect(&mut s, Rect::new(50.0, 50.0, 150.0, 150.0), gr, false); + run(&mut s, "object.expand", json!({"steps": 4})); + let g = selected(&s); + let (_, rings) = clip_parts(&g); + let subpaths = |n: &Node| n.path_data().unwrap().subpaths.len(); + assert_eq!(rings.iter().map(|r| subpaths(r)).collect::>(), [2, 2, 2, 1]); + let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = rings[3].appearance.items.first() else { panic!() }; + assert!(f.opacity < 1.0 && f.opacity > 0.5, "the centre band is translucent: {}", f.opacity); +} + +#[test] +fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { + for gradient in [ + json!({"kind": "linear", "angle": 30, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.4, "color": "#00ff00", "midpoint": 0.3}, {"offset": 1, "color": "#0000ff"}]}), + json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]}), + ] { + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient.clone(), false); + let before = render(&s); + run(&mut s, "object.expand", json!({"gradient": "mesh"})); + let g = selected(&s); + let (_, content) = clip_parts(&g); + assert_eq!(content.len(), 1); + let NodeKind::Mesh(m) = &content[0].kind else { panic!("a mesh node: {:?}", content[0].kind) }; + assert!(m.is_valid()); + // The mesh is drawn as small flat pieces, so it is close but not identical. + let after = render(&s); + assert_eq!(differing(&before, &after, 48), 0, "{gradient}"); + assert!(mean_diff(&before, &after) < 2.5, "{gradient}: {}", mean_diff(&before, &after)); + } +} + +#[test] +fn a_hard_stop_keeps_its_edge_in_the_mesh() { + let mut s = session(); + let gr = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#ff0000"}, {"offset": 0.5, "color": "#0000ff"}, {"offset": 1, "color": "#0000ff"}]}); + gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), gr, false); + run(&mut s, "object.expand", json!({"gradient": "mesh"})); + let g = selected(&s); + let (_, content) = clip_parts(&g); + let NodeKind::Mesh(m) = &content[0].kind else { panic!() }; + // Columns at 0, 0.5 (red), 0.5 (blue), 1: the middle patch has no width. + assert_eq!(m.cols, 3); + let (a, b) = (&m.points[m.idx(0, 1)], &m.points[m.idx(0, 2)]); + assert!(a.p.distance(b.p) < 1e-9); + assert_eq!((a.color.to_hex(), b.color.to_hex()), ("#ff0000".into(), "#0000ff".into())); +} + +#[test] +fn a_freeform_gradient_expands_to_a_mesh_shaped_like_the_object() { + let mut s = session(); + let gr = json!({"kind": "freeform", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]}); + gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), gr, false); + let before = render(&s); + // Even with Specify … Objects. + run(&mut s, "object.expand", json!({"gradient": "objects"})); + let n = selected(&s); + let NodeKind::Mesh(m) = &n.kind else { panic!("a mesh: {:?}", n.kind) }; + assert!(m.rows > 1 && m.cols > 1); + let b = m.outline().bounds().unwrap(); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 180.0).abs() < 1e-6, "{b:?}"); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.5, "{}", mean_diff(&before, &after)); +} + +#[test] +fn create_gradient_mesh_takes_its_colours_from_the_gradient() { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 50.0), white_black(), false); + run(&mut s, "object.mesh.create", json!({"rows": 1, "cols": 2})); + let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() }; + let hex = |r: usize, c: usize| m.points[m.idx(r, c)].color.to_hex(); + for r in 0..=1 { + assert_eq!(hex(r, 0), "#ffffff", "left column white"); + assert_eq!(hex(r, 2), "#000000", "right column black"); + assert_eq!(hex(r, 1), "#808080", "middle grey"); + } + // A solid fill still gives one colour, the highlight still lightens. + let mut s = session(); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "object.mesh.create", json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100})); + let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() }; + assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#ff0000"); + assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff"); +} + +#[test] +fn expanded_colours_keep_the_stops_colour_model() { + let mut s = session(); + let gr = json!({"kind": "linear", "stops": [{"offset": 0, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 1, "y": 0, "k": 0}}]}); + gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), gr, false); + run(&mut s, "object.expand", json!({"steps": 4})); + let g = selected(&s); + let (_, strips) = clip_parts(&g); + let Color::Cmyk { c, m, .. } = fill_color(strips[1]) else { panic!("CMYK strip expected: {:?}", fill_color(strips[1])) }; + assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}"); +} + +#[test] +fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() { + // Fill unchecked: the stroke is outlined, the gradient fill stays a gradient. + let mut s = session(); + gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true); + run(&mut s, "object.expand", json!({"fill": false})); + let g = selected(&s); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("fill + outlined stroke group: {:?}", g.kind) }; + assert_eq!(children.len(), 2); + assert!(matches!(children[0].appearance.fill_paint(), Paint::Gradient(_)), "the fill is still live"); + assert_eq!(fill_color(&children[1]).to_hex(), "#0000ff", "the stroke became a filled outline"); + // Stroke unchecked: the gradient is expanded and the stroke stays a stroke on a copy above. + let mut s = session(); + gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true); + let before = render(&s); + run(&mut s, "object.expand", json!({"stroke": false, "steps": 255})); + let g = selected(&s); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("{:?}", g.kind) }; + assert_eq!(clip_parts(&children[0]).1.len(), 255); + assert_eq!(children[1].appearance.stroke_paint().color().unwrap().to_hex(), "#0000ff"); + assert!(children[1].appearance.fill_paint().is_none()); + assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same"); +} + +#[test] +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})); + 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. + let g = selected(&s); + let NodeKind::Group { children, .. } = &g.kind else { panic!("{:?}", g.kind) }; + let (clip, strips) = clip_parts(&children[0]); + assert!(matches!(clip.kind, NodeKind::Path { live: None, .. })); + 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!(s.execute("object.expand", &json!({})).is_err(), "nothing left to expand"); + // Bad options are refused before anything changes. + run(&mut s, "edit.undo", json!({})); + assert!(s.execute("object.expand", &json!({"gradient": "rings"})).is_err()); + assert!(s.execute("object.expand", &json!({"steps": 0})).is_err()); + assert!(matches!(selected(&s).kind, NodeKind::Path { live: Some(_), .. })); +} diff --git a/crates/ui-egui/src/dialogs/expand.rs b/crates/ui-egui/src/dialogs/expand.rs new file mode 100644 index 00000000..a14204cd --- /dev/null +++ b/crates/ui-egui/src/dialogs/expand.rs @@ -0,0 +1,146 @@ +//! Object → Expand: what to expand (Object: live shapes, type and effects; Fill: gradient 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`). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::expand::{DEFAULT_STEPS, MAX_STEPS}; + +use super::{DialogSpec, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Expand. +pub const KIND: &str = "expand"; + +const CMD: &str = "object.expand"; + +/// The options and their checkbox labels. +const OPTIONS: [(&str, &str); 3] = [("object", "Object"), ("fill", "Fill"), ("stroke", "Stroke")]; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Expand".into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM }; + +/// Open Expand for the selection, every option on (as `object.expand {}` does). +pub fn open(app: &mut VectorcraftApp) -> Result { + let info = app.session.execute("object.expand.info", &json!({})).map_err(|e| e.to_string())?; + let mut fields = json!({"object": true, "fill": true, "stroke": true, "gradient": "objects", "steps": DEFAULT_STEPS}); + for (key, _) in OPTIONS { + fields[format!("__{key}")] = info[key].clone(); + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let set = |d: &mut Dialog, key: &str, v: Value| { + d.fields.insert(key.into(), v); + }; + widgets::subheader(ui, "Expand"); + for (key, label) in OPTIONS { + let on = d.bool(key); + ui.horizontal(|ui| { + ui.add_space(12.0); + if widgets::check(ui, label, on, d.bool(&format!("__{key}"))) { + set(d, key, json!(!on)); + } + }); + } + ui.add_space(10.0); + widgets::subheader(ui, "Expand Gradient To"); + // Only gradient fills that Fill expands care. + let enabled = d.bool("__fill") && d.bool("fill"); + let objects = d.str("gradient") != "mesh"; + ui.horizontal(|ui| { + ui.add_space(12.0); + if widgets::radio(ui, "Gradient Mesh", !objects, enabled) { + set(d, "gradient", json!("mesh")); + } + }); + ui.horizontal(|ui| { + ui.add_space(12.0); + if widgets::radio(ui, "Specify:", objects, enabled) { + set(d, "gradient", json!("objects")); + } + ui.add_enabled_ui(enabled && objects, |ui| { + if let Some(n) = widgets::plain_field(ui, "expand-steps", d.f64("steps", DEFAULT_STEPS as f64), "", 0, 52.0) { + set(d, "steps", json!(n.round().clamp(1.0, MAX_STEPS as f64))); + } + widgets::dim_label(ui, "Objects"); + }); + }); + false +} + +/// `object.expand` parameters from the fields. +fn params(d: &Dialog) -> Value { + json!({ + "object": d.bool("object"), + "fill": d.bool("fill"), + "stroke": d.bool("stroke"), + "gradient": d.str("gradient"), + "steps": d.f64("steps", DEFAULT_STEPS as f64), + }) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + run_and_close(app, CMD, params(d)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + use vectorcraft_engine::doc::NodeKind; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + #[test] + fn opens_with_what_applies_and_ok_expands_the_gradient_to_a_mesh() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap(); + app.run("paint.setFill", json!({"gradient": {"kind": "linear"}})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + // The menu item (and `object.expand` without params) opens the dialog. + crate::menus::invoke(&mut app, "object.expand", json!({})); + let d = app.ui.dialog.as_ref().expect("the Expand dialog"); + assert_eq!(d.kind, KIND); + assert_eq!((d.bool("__object"), d.bool("__fill"), d.bool("__stroke")), (true, true, false), "no stroke to expand"); + assert_eq!(params(d), json!({"object": true, "fill": true, "stroke": true, "gradient": "objects", "steps": 255.0})); + let text = frame(&mut app); + for label in ["Expand", "Object", "Fill", "Stroke", "Expand Gradient To", "Gradient Mesh", "Specify:", "Objects", "OK", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + let undo = app.session.doc().unwrap().history.undo.len(); + app.ui.dialog.as_mut().unwrap().fields.insert("gradient".into(), json!("mesh")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + let g = st.doc.node(st.selection.objects[0]).unwrap(); + let NodeKind::Group { children, clip: true } = &g.kind else { panic!("a clip group: {:?}", g.kind) }; + assert!(matches!(children[1].kind, NodeKind::Mesh(_))); + } + + #[test] + fn the_command_palette_opens_it_and_ui_expand_dialog_too() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap(); + app.run("ui.expandDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.bool("__object"), d.bool("__fill"), d.bool("__stroke")), (true, false, true)); + frame(&mut app); + // Explicit params still run the command directly. + app.ui.dialog = None; + crate::menus::invoke(&mut app, "object.expand", json!({"stroke": false})); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + assert!(matches!(st.doc.node(st.selection.objects[0]).unwrap().kind, NodeKind::Path { live: None, .. })); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index a928798d..9feb7989 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -16,6 +16,7 @@ mod command; pub mod confirm; mod document_setup; mod effect; +pub mod expand; mod export_for_screens; pub mod eyedropper; pub mod flatten; @@ -162,6 +163,7 @@ registry! { FlattenTransparency: [flatten::KIND] => flatten::SPEC, FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC, SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC, + Expand: [expand::KIND] => expand::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 12967fdb..c01f2ab0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -309,6 +309,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), + ( + "ui.expandDialog", + "Expand…", + "", + "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -675,6 +681,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), None => Err("no such user library".into()), }, + "ui.expandDialog" => crate::dialogs::expand::open(app), _ => return None, }; Some(r) @@ -859,6 +866,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), + "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), _ => true, } } @@ -1042,7 +1050,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ), c("Show All", "object.showAll"), Sep, - cp("Expand…", "object.expand", json!({"object": true, "fill": true, "stroke": true})), + c("Expand…", "ui.expandDialog"), c("Expand Appearance", "effect.expandAppearance"), c("Crop Image", "object.cropImage"), cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent"})), @@ -1656,6 +1664,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Expand…: its dialog. + if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::expand::open(app) { + app.status(e); + } + return; + } // Help links: the command returns the URL; a menu click opens it (agents just get the URL). if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { app.open_link(id); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 431639a9..c44806c4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -138,3 +138,9 @@ overprints?, preset?, options?, showOptions?}` sets it (a preset loads its optio and refreshes its snapshot of the document, answering as `flattener.preview` does. `ui.inspect` reports the settings as `ui.flattener_preview`. The panel keeps its snapshot until Refresh (it notes when the document or the settings changed since); its preview greys the art and paints the highlighted objects or areas red. + +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 +one undo step. diff --git a/docs/mcp.md b/docs/mcp.md index 566531aa..cf797a29 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -549,3 +549,21 @@ Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter limit. A rectangle's right-angle miters stay within half the weight of its edges. + +## Expand + +`object.expand {object?, fill?, stroke?, gradient?, steps?}` (Object → Expand…) turns the selection into plain art in +one undo step. `object` (on by default) outlines type, turns live shapes into paths and bakes effects; `stroke` (on) +outlines strokes into filled paths; `fill` (on) expands gradient fills: with `gradient: "objects"` (the default) into +`steps` (1–1000, default 255) solid objects (rectangles across a linear gradient, each from its band to the far end so +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. + +```json +{"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} +{"name":"run_command","arguments":{"command":"object.expand","params":{"steps":16}}} +```